diff --git a/app/api/cart/bulk/route.ts b/app/api/cart/bulk/route.ts new file mode 100644 index 0000000..a25d36e --- /dev/null +++ b/app/api/cart/bulk/route.ts @@ -0,0 +1,84 @@ +// ********************* +// Role: Bulk Cart Operations API Route +// Purpose: Handles bulk operations for the shopping cart +// Endpoints: +// - POST: Adds all materials from a project to the cart +// Features: +// - Fetches all products from a project +// - Replaces current cart contents with project materials +// - Maintains quantities from project specifications +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// Type declaration for global cart storage +declare global { + var cartItems: { [key: string]: Array<{ productId: string; quantity: number }> }; +} + +// Initialize global cart storage if not exists +if (!global.cartItems) { + global.cartItems = {}; +} + +// POST handler: Add all project materials to cart +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body for project ID + const body = await request.json(); + const { projectId } = body; + + if (!projectId) { + return new NextResponse("Project ID is required", { status: 400 }); + } + + // Fetch all products associated with the project + const projectProducts = await prisma.projectProduct.findMany({ + where: { + projectId: projectId + }, + include: { + product: true + } + }); + + if (!projectProducts.length) { + return new NextResponse("Project has no products", { status: 400 }); + } + + // Ensure cart storage is initialized + if (!global.cartItems) { + global.cartItems = {}; + } + + // Initialize or clear user's cart + const userEmail = session.user.email as string; + if (!global.cartItems[userEmail]) { + global.cartItems[userEmail] = []; + } + + // Transform project products to cart format + const cartItems = projectProducts.map(pp => ({ + productId: pp.productId, + quantity: pp.quantity + })); + + // Replace current cart contents with project materials + global.cartItems[userEmail] = cartItems; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[CART_BULK_POST]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/cart/route.ts b/app/api/cart/route.ts new file mode 100644 index 0000000..1aa45d6 --- /dev/null +++ b/app/api/cart/route.ts @@ -0,0 +1,108 @@ +// ********************* +// Role: Cart API Route Handler +// Purpose: Manages shopping cart operations +// Endpoints: +// - GET: Retrieves all items in the user's cart with product details +// - POST: Adds a new item to the cart +// - DELETE: Clears the user's cart +// Storage: Uses global server-side storage keyed by user email +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// GET handler: Retrieve cart items with product details +export async function GET() { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Get cart items from session storage + const cartItems = global.cartItems?.[session.user?.email as string] || []; + + // Fetch full product details for all cart items + const products = await prisma.product.findMany({ + where: { + id: { + in: cartItems.map(item => item.productId) + } + } + }); + + // Combine cart quantities with product details + const cartWithDetails = cartItems.map(item => { + const product = products.find(p => p.id === item.productId); + return { + ...item, + product + }; + }); + + return NextResponse.json(cartWithDetails); + } catch (error) { + console.error("[CART_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// POST handler: Add new item to cart +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body for product details + const body = await request.json(); + const { productId, quantity } = body; + + // Initialize global cart storage if needed + if (!global.cartItems) { + global.cartItems = {}; + } + + // Initialize user's cart array if needed + const userEmail = session.user?.email as string; + if (!global.cartItems[userEmail]) { + global.cartItems[userEmail] = []; + } + + // Add new item to user's cart + global.cartItems[userEmail].push({ productId, quantity }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[CART_POST]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// DELETE handler: Clear all items from cart +export async function DELETE() { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Reset user's cart to empty array + const userEmail = session.user?.email as string; + if (global.cartItems?.[userEmail]) { + global.cartItems[userEmail] = []; + } + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("[CART_DELETE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/materials/templates/route.ts b/app/api/contractor/materials/templates/route.ts new file mode 100644 index 0000000..659708f --- /dev/null +++ b/app/api/contractor/materials/templates/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +// GET /api/contractor/materials/templates +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const templates = await prisma.materialTemplate.findMany(); + return NextResponse.json(templates); + } catch (error) { + console.error("Error fetching material templates:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/contractor/materials/templates +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { name, description, unit } = body; + + const template = await prisma.materialTemplate.create({ + data: { + name, + description, + unit, + }, + }); + + return NextResponse.json(template); + } catch (error) { + console.error("Error creating material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PUT /api/contractor/materials/templates/[templateId] +export async function PUT(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { templateId, name, description, unit } = body; + + const template = await prisma.materialTemplate.update({ + where: { id: templateId }, + data: { + name, + description, + unit, + }, + }); + + return NextResponse.json(template); + } catch (error) { + console.error("Error updating material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// DELETE /api/contractor/materials/templates/[templateId] +export async function DELETE(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const templateId = searchParams.get("templateId"); + + if (!templateId) { + return NextResponse.json( + { error: "Template ID is required" }, + { status: 400 } + ); + } + + await prisma.materialTemplate.delete({ + where: { id: templateId }, + }); + + return NextResponse.json({ message: "Template deleted successfully" }); + } catch (error) { + console.error("Error deleting material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/files/route.ts b/app/api/contractor/projects/[projectId]/files/route.ts new file mode 100644 index 0000000..cd721a4 --- /dev/null +++ b/app/api/contractor/projects/[projectId]/files/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; +import { v4 as uuidv4 } from "uuid"; +import { promises as fs } from "fs"; +import path from "path"; + +export async function GET( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Get project files from database + const files = await prisma.ProjectFile.findMany({ + where: { + projectId: params.projectId + }, + orderBy: { + uploadedAt: 'desc' + } + }); + + return NextResponse.json(files); + } catch (error) { + console.error("Error fetching project files:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("files") as File; + + if (!file) { + return new NextResponse("No file uploaded", { status: 400 }); + } + + if (file.type !== "application/pdf") { + return new NextResponse("Only PDF files are allowed", { status: 400 }); + } + + // Create unique filename + const uniqueFileName = `${Date.now()}-${file.name}`; + + // Create project-specific directory + const projectUploadPath = path.join( + process.cwd(), + "server", + "uploads", + params.projectId + ); + + await fs.mkdir(projectUploadPath, { recursive: true }); + + // Save file to disk + const filePath = path.join('uploads', params.projectId, uniqueFileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + const buffer = await file.arrayBuffer(); + await fs.writeFile(fullPath, new Uint8Array(buffer)); + + // Create file record in database + const projectFile = await prisma.ProjectFile.create({ + data: { + projectId: params.projectId, + filename: uniqueFileName, + originalname: file.name, + mimetype: file.type, + size: buffer.byteLength, + path: filePath, + } + }); + + return NextResponse.json(projectFile); + } catch (error) { + console.error("Error creating project file:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts new file mode 100644 index 0000000..745a469 --- /dev/null +++ b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +// PUT handler: Update a material +export async function PUT( + request: Request, + { params }: { params: { projectId: string; materialId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Parse the request body + const body = await request.json(); + const { quantity } = body; + + const projectProduct = await prisma.projectProduct.update({ + where: { + id: params.materialId, + projectId: params.projectId, + }, + data: { + quantity, + }, + include: { + product: true, + }, + }); + + // Transform to match expected format + const material = { + id: projectProduct.id, + name: projectProduct.product.title, + description: projectProduct.product.description, + quantity: projectProduct.quantity, + unit: "piece", + productId: projectProduct.product.id + }; + + return NextResponse.json(material); + } catch (error) { + console.error("[MATERIALS_PUT]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// DELETE handler: Delete a material +export async function DELETE( + request: Request, + { params }: { params: { projectId: string; materialId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists and get current itemCount + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Use a transaction to ensure both operations succeed or fail together + await prisma.$transaction([ + prisma.projectProduct.delete({ + where: { + id: params.materialId, + projectId: params.projectId, + }, + }), + prisma.project.update({ + where: { + id: params.projectId, + }, + data: { + // Ensure itemCount never goes below 0 + itemCount: Math.max(0, (project.itemCount || 0) - 1), + }, + }), + ]); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("[MATERIALS_DELETE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/materials/route.ts b/app/api/contractor/projects/[projectId]/materials/route.ts new file mode 100644 index 0000000..a0499f2 --- /dev/null +++ b/app/api/contractor/projects/[projectId]/materials/route.ts @@ -0,0 +1,147 @@ +// ********************* +// Role: Materials API Route Handler +// Purpose: Manages materials for a specific project +// Endpoints: +// - GET: Retrieves all materials for a project +// - POST: Adds a new material to a project +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +// Helper function to ensure image URL is absolute +function getAbsoluteImageUrl(imagePath: string | null) { + // Use placeholder if image path is missing or empty + if (!imagePath) { + return 'https://placehold.co/400x400?text=Product'; + } + if (imagePath.startsWith('http')) return imagePath; + // For relative paths, first ensure they start with / + const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`; + return `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}${normalizedPath}`; +} + +type ProjectProductWithProduct = { + id: string; + quantity: number; + projectId: string; + productId: string; + product: { + title: string; + description: string; + id: string; + price: number; + mainImage: string; + }; +} + +// GET handler: Fetch all materials for a project +export async function GET( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Fetch materials with associated product data + const materials = await prisma.projectProduct.findMany({ + where: { + projectId: params.projectId, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Transform the data to include name property + const transformedMaterials = materials.map((material: any) => ({ + ...material, + name: material.product?.title || "Unknown Material", + unit: "piece", // Default unit + })); + + return NextResponse.json(transformedMaterials); + } catch (error) { + console.error("Error fetching materials:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +// POST handler: Add a new material to a project +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body + const body = await request.json(); + const { productId, quantity } = body; + + // Validate required fields + if (!productId || !quantity) { + return new NextResponse("Missing required fields", { status: 400 }); + } + + // Verify product exists + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + return new NextResponse("Product not found", { status: 404 }); + } + + // Create new project product entry + const material = await prisma.projectProduct.create({ + data: { + projectId: params.projectId, + productId, + quantity, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Add name property to response + const transformedMaterial = { + ...material, + name: material.product.title, + unit: "piece", + }; + + return NextResponse.json(transformedMaterial); + } catch (error) { + console.error("Error creating material:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/recalculate/route.ts b/app/api/contractor/projects/[projectId]/recalculate/route.ts new file mode 100644 index 0000000..88d213f --- /dev/null +++ b/app/api/contractor/projects/[projectId]/recalculate/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// POST handler: Recalculate project item count +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + include: { + products: true, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Count the actual number of materials + const actualCount = project.products.length; + + // Update the project with the correct count + const updatedProject = await prisma.project.update({ + where: { + id: params.projectId, + }, + data: { + itemCount: actualCount, + }, + }); + + return NextResponse.json({ itemCount: updatedProject.itemCount }); + } catch (error) { + console.error("[PROJECT_RECALCULATE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/route.ts b/app/api/contractor/projects/[projectId]/route.ts new file mode 100644 index 0000000..36dad0f --- /dev/null +++ b/app/api/contractor/projects/[projectId]/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +export async function PUT( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { name } = await request.json(); + if (!name || typeof name !== "string") { + return new NextResponse("Invalid project name", { status: 400 }); + } + + const project = await prisma.Project.update({ + where: { + id: params.projectId, + contractorId: session.user.id, + }, + data: { + name, + }, + }); + + return NextResponse.json(project); + } catch (error) { + console.error("Error updating project:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First delete all project products associated with this project + await prisma.ProjectProduct.deleteMany({ + where: { + projectId: params.projectId, + }, + }); + + // Then delete the project itself + await prisma.Project.delete({ + where: { + id: params.projectId, + contractorId: session.user.id, + }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Error deleting project:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/route.ts b/app/api/contractor/projects/route.ts new file mode 100644 index 0000000..c2beee6 --- /dev/null +++ b/app/api/contractor/projects/route.ts @@ -0,0 +1,75 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import prisma from "@/server/utills/db"; + +export async function GET() { + try { + const session = await getServerSession(); + + if (!session?.user?.email) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const projects = await prisma.project.findMany({ + where: { contractorId: user.id }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(projects); + } catch (error) { + console.error("[PROJECTS_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + const session = await getServerSession(); + console.log("[PROJECTS_POST] Session:", session); + + if (!session?.user?.email) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + console.log("[PROJECTS_POST] User:", user); + + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const body = await req.json(); + console.log("[PROJECTS_POST] Request body:", body); + const { name } = body; + + if (!name) { + return new NextResponse("Name is required", { status: 400 }); + } + + console.log("[PROJECTS_POST] Creating project with:", { name, contractorId: user.id }); + const project = await prisma.Project.create({ + data: { + name, + contractorId: user.id, + }, + }); + console.log("[PROJECTS_POST] Created project:", project); + + return NextResponse.json(project); + } catch (error) { + console.error("[PROJECTS_POST] Error details:", error); + // Return more specific error message if available + const errorMessage = error instanceof Error ? error.message : "Internal error"; + return new NextResponse(errorMessage, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/upload-material/route.ts b/app/api/contractor/projects/upload-material/route.ts new file mode 100644 index 0000000..3132c4f --- /dev/null +++ b/app/api/contractor/projects/upload-material/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; +import prisma from "@/server/utills/db"; + +interface Material { + productId: string; + quantity: number; +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { projectId, filePath } = await req.json(); + + if (!projectId || !filePath) { + return new NextResponse("Project ID and file path are required", { + status: 400, + }); + } + + try { + // Verify the project exists and belongs to the user + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return new NextResponse("File not found", { status: 404 }); + } + + // In a real implementation, you would parse the PDF here + // and extract materials list + + // Create a placeholder material for demo purposes + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "placeholder-product-id", // You would determine this from the file + quantity: 1, + }, + }); + + return NextResponse.json({ + message: "Materials uploaded successfully", + material: newMaterial + }); + } catch (error) { + console.error("Error uploading materials:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/app/api/contractor/projects/uploaded-files/route.ts b/app/api/contractor/projects/uploaded-files/route.ts new file mode 100644 index 0000000..94cf0b8 --- /dev/null +++ b/app/api/contractor/projects/uploaded-files/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +export async function GET() { + const uploadDir = path.join(process.cwd(), "server", "uploads"); + + try { + // Create the uploads directory if it doesn't exist + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Check if directory exists before reading + const dirExists = await fs.promises.access(uploadDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + return NextResponse.json([]); + } + + const files = await fs.promises.readdir(uploadDir); + const pdfFiles = files + .filter((file) => file.endsWith(".pdf")) + .map((file) => ({ + name: file, + path: `uploads/${file}`, + })); + + return NextResponse.json(pdfFiles); + } catch (error) { + console.error("Error reading upload directory:", error); + // Return empty array instead of error when directory doesn't exist or can't be read + return NextResponse.json([]); + } +} diff --git a/app/api/products/route.ts b/app/api/products/route.ts new file mode 100644 index 0000000..67a0bda --- /dev/null +++ b/app/api/products/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/db"; + +// GET /api/products +export async function GET() { + try { + const products = await prisma.product.findMany({ + select: { + id: true, + title: true, + description: true, + price: true, + inStock: true, + }, + where: { + inStock: { + gt: 0 + } + }, + orderBy: { + title: 'asc' + } + }); + + return NextResponse.json(products); + } catch (error) { + console.error("[PRODUCTS_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/projects/upload-material/route.ts b/app/api/projects/upload-material/route.ts new file mode 100644 index 0000000..249c311 --- /dev/null +++ b/app/api/projects/upload-material/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; +import prisma from "@/utils/db"; + +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body + const { projectId, filePath } = await request.json(); + + if (!projectId || !filePath) { + return new NextResponse("Project ID and file path are required", { + status: 400, + }); + } + + // Verify the project exists + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return new NextResponse("File not found", { status: 404 }); + } + + // Create a placeholder material for demo purposes + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "default-product-id", // Use a default product ID + quantity: 1, + }, + include: { + product: true, + }, + }); + + // Update the project's item count + await prisma.project.update({ + where: { id: projectId }, + data: { itemCount: { increment: 1 } }, + }); + + return NextResponse.json({ + message: "Materials uploaded successfully", + material: { + id: newMaterial.id, + name: newMaterial.product.title, + description: newMaterial.product.description, + quantity: newMaterial.quantity, + unit: "piece", + productId: newMaterial.product.id + } + }); + } catch (error) { + console.error("Error uploading material from file:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/projects/uploaded-files/route.ts b/app/api/projects/uploaded-files/route.ts new file mode 100644 index 0000000..686a264 --- /dev/null +++ b/app/api/projects/uploaded-files/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; + +export async function GET(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Create uploads directory if it doesn't exist + const uploadDir = path.join(process.cwd(), "server", "uploads"); + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Check if directory exists before reading + const dirExists = await fs.promises.access(uploadDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + return NextResponse.json([]); + } + + // Read the directory + const files = await fs.promises.readdir(uploadDir); + + // Filter for PDF files + const pdfFiles = files + .filter((file) => file.endsWith(".pdf")) + .map((file) => ({ + name: file, + path: `uploads/${file}`, + })); + + return NextResponse.json(pdfFiles); + } catch (error) { + console.error("Error fetching uploaded files:", error); + return NextResponse.json([]); // Return empty array instead of error + } +} \ No newline at end of file diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..d03adc2 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { v4 as uuidv4 } from "uuid"; +import { promises as fs } from "fs"; +import path from "path"; + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("uploadedFile") as File; + const projectId = formData.get("projectId") as string; + + if (!file) { + return new NextResponse("No file uploaded", { status: 400 }); + } + + if (!projectId) { + return new NextResponse("Project ID is required", { status: 400 }); + } + + if (file.type !== "application/pdf") { + return new NextResponse("Only PDF files are allowed", { status: 400 }); + } + + const uniqueFileName = `${uuidv4()}-${file.name}`; + + // Create project-specific directory + const projectUploadPath = path.join( + process.cwd(), + "server", + "uploads", + projectId + ); + + await fs.mkdir(projectUploadPath, { recursive: true }); + + const uploadPath = path.join(projectUploadPath, uniqueFileName); + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(uploadPath, Uint8Array.from(buffer)); + + return NextResponse.json({ + filePath: `uploads/${projectId}/${uniqueFileName}`, + fileName: uniqueFileName, + originalName: file.name, + size: buffer.length, + mimeType: file.type + }); +} diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx new file mode 100644 index 0000000..2ff2d11 --- /dev/null +++ b/app/projects/[projectId]/materials/page.tsx @@ -0,0 +1,572 @@ +// ********************* +// Role: Project Materials Management Page +// Purpose: Allows contractors to manage materials for a specific project +// Features: +// - Lists all materials in a project +// - Adds new materials from product catalog +// - Updates material quantities +// - Removes materials from project +// - Maintains project item count +// ********************* + +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { toast } from "react-hot-toast"; + +// Type definitions for data structures +interface Material { + id: string; + name: string; + description: string | null; + quantity: number; + unit: string; + productId: string; + product?: { + id: string; + title: string; + description?: string; + price: number; + mainImage: string; + }; +} + +interface Product { + id: string; + title: string; + description: string; + price: number; + inStock: number; +} + +interface ProjectFile { + id: string; + filename: string; + originalname: string; + path: string; + mimetype: string; + size: number; + uploadedAt: string; +} + +export default function MaterialsPage() { + const [materials, setMaterials] = useState([]); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedProduct, setSelectedProduct] = useState(""); + const [newQuantity, setNewQuantity] = useState(1); + const [editingMaterial, setEditingMaterial] = useState(null); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [selectedFile, setSelectedFile] = useState(""); + const [selectedFilesForImport, setSelectedFilesForImport] = useState([]); + + const params = useParams(); + const router = useRouter(); + const { data: session } = useSession(); + + useEffect(() => { + const fetchData = async () => { + try { + const materialsResponse = await fetch( + `/api/contractor/projects/${params.projectId}/materials` + ); + if (!materialsResponse.ok) { + throw new Error("Failed to fetch materials"); + } + const materialsData = await materialsResponse.json(); + + const processedMaterials = materialsData.map((material: any) => ({ + ...material, + name: material.name || material.product?.title || "Unknown Material", + })); + + setMaterials(processedMaterials); + + const productsResponse = await fetch(`/api/products`); + if (!productsResponse.ok) { + throw new Error("Failed to fetch products"); + } + const productsData = await productsResponse.json(); + setProducts(productsData); + + // Fetch uploaded files + const filesResponse = await fetch( + `/api/contractor/projects/${params.projectId}/files` + ); + if (!filesResponse.ok) { + throw new Error("Failed to fetch uploaded files"); + } + const filesData = await filesResponse.json(); + setUploadedFiles(filesData); + } catch (error) { + console.error("Error fetching data:", error); + toast.error("Failed to load data"); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [params.projectId]); + + const handleAddMaterial = async () => { + try { + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + productId: selectedProduct, + quantity: newQuantity, + }), + } + ); + + if (!response.ok) { + throw new Error("Failed to add material"); + } + + const newMaterial = await response.json(); + + const materialWithName = { + ...newMaterial, + name: + newMaterial.name || newMaterial.product?.title || "Unknown Material", + }; + + setMaterials([...materials, materialWithName]); + + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { + method: "POST", + }); + + toast.success("Material added successfully"); + setSelectedProduct(""); + setNewQuantity(1); + } catch (error) { + console.error("Error adding material:", error); + toast.error("Failed to add material"); + } + }; + + const handleUpdateQuantity = async ( + materialId: string, + newQuantity: number + ) => { + try { + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials/${materialId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + quantity: newQuantity, + }), + } + ); + + if (!response.ok) { + throw new Error("Failed to update quantity"); + } + + setMaterials( + materials.map((material) => + material.id === materialId + ? { ...material, quantity: newQuantity } + : material + ) + ); + toast.success("Quantity updated successfully"); + } catch (error) { + console.error("Error updating quantity:", error); + toast.error("Failed to update quantity"); + } + }; + + const handleDeleteMaterial = async (materialId: string) => { + try { + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials/${materialId}`, + { + method: "DELETE", + } + ); + + if (!response.ok) { + throw new Error("Failed to delete material"); + } + + setMaterials(materials.filter((material) => material.id !== materialId)); + + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { + method: "POST", + }); + + toast.success("Material deleted successfully"); + } catch (error) { + console.error("Error deleting material:", error); + toast.error("Failed to delete material"); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const files = e.target.files; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.size > 20 * 1024 * 1024) { + toast.error('File size exceeds 20MB'); + return; + } + if (file.type !== 'application/pdf') { + toast.error('Only PDF files are allowed'); + return; + } + } + + const formData = new FormData(); + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + try { + const response = await fetch(`/api/contractor/projects/${params.projectId}/files`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Failed to upload files'); + } + + const result = await response.json(); + + // Refresh the file list + const filesResponse = await fetch(`/api/contractor/projects/${params.projectId}/files`); + if (!filesResponse.ok) { + throw new Error('Failed to fetch uploaded files'); + } + const filesData = await filesResponse.json(); + setUploadedFiles(filesData); + toast.success('Files uploaded successfully'); + } catch (error) { + console.error('Error uploading files:', error); + toast.error('Failed to upload files'); + } + }; + + const toggleFileSelection = (filePath: string) => { + console.log("Current selection:", selectedFilesForImport); + console.log("Toggling file:", filePath); + + setSelectedFilesForImport(prev => { + const newSelection = prev.includes(filePath) + ? prev.filter(path => path !== filePath) + : [...prev, filePath]; + + console.log("New selection:", newSelection); + return newSelection; + }); + }; + + const handleImportList = async () => { + if (selectedFilesForImport.length === 0) { + toast.error("Please select at least one file to import"); + return; + } + + try { + setLoading(true); + + // Process each selected file + for (const filePath of selectedFilesForImport) { + const response = await fetch(`/api/projects/upload-material`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId: params.projectId, + filePath: filePath, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to import from file: ${filePath}`); + } + } + + // Refresh the materials list + const materialsResponse = await fetch( + `/api/contractor/projects/${params.projectId}/materials` + ); + + if (!materialsResponse.ok) { + throw new Error("Failed to refresh materials list"); + } + + const materialsData = await materialsResponse.json(); + const processedMaterials = materialsData.map((material: any) => ({ + ...material, + name: material.name || material.product?.title || "Unknown Material", + })); + + setMaterials(processedMaterials); + + // Clear the selection + setSelectedFilesForImport([]); + + toast.success("Materials imported successfully"); + } catch (error) { + console.error("Error importing materials:", error); + toast.error("Failed to import materials"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+

Project Materials

+ +
+ {/* Left sidebar - File list */} +
+
+
+

Files

+ +
+ + {uploadedFiles.length === 0 ? ( +

No files uploaded yet.

+ ) : ( +
+ {uploadedFiles.map((file) => ( +
+ toggleFileSelection(file.path)} + className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + /> + + + {new Date(file.uploadedAt).toLocaleDateString()} + +
+ ))} +
+ )} +
+
+ + {/* Main content area */} +
+
+

Add New Material

+
+ + setNewQuantity(parseInt(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Quantity" + /> + +
+
+ +
+

Upload Material from PDF

+
+ +
+ + + Only PDF files up to 20MB are allowed + +
+
+
+ +
+ + + + + + + + + + + + {materials.map((material) => ( + + + + + + + + ))} + +
+ Material Name + + Description + + Quantity + + Unit + + Actions +
+ {material.name} + + {material.description || "-"} + + {editingMaterial?.id === material.id ? ( + + setEditingMaterial({ + ...editingMaterial, + quantity: parseInt(e.target.value), + }) + } + className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> + ) : ( + material.quantity + )} + + {material.unit} + + {editingMaterial?.id === material.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+
+ ); +} diff --git a/app/projects/layout.tsx b/app/projects/layout.tsx new file mode 100644 index 0000000..372b212 --- /dev/null +++ b/app/projects/layout.tsx @@ -0,0 +1,36 @@ +// ********************* +// Role of the component: Projects Layout wrapper +// Purpose: Provides authentication check and consistent layout for all project pages +// Features: +// - Checks for valid user session +// - Redirects to login if not authenticated +// - Provides consistent padding and maximum width for content +// ********************* + +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { redirect } from "next/navigation"; + +export default async function ProjectsLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Check for authenticated session + const session = await getServerSession(); + if (!session?.user) { + redirect("/login"); + } + + return ( + // Main container with consistent background and minimum height +
+
+
+ {/* Content wrapper with maximum width and responsive padding */} +
{children}
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/projects/page.tsx b/app/projects/page.tsx new file mode 100644 index 0000000..4ca66e2 --- /dev/null +++ b/app/projects/page.tsx @@ -0,0 +1,365 @@ +// ********************* +// Role: Projects List Page +// Purpose: Displays and manages all projects for the contractor +// Features: +// - Lists all projects with their details (name, date, item count) +// - Allows editing project names +// - Enables project deletion +// - Provides navigation to create new projects +// - Handles checkout functionality for project materials +// ********************* + +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; +import { FaEdit, FaTrash } from "react-icons/fa"; +import { useProductStore } from "../_zustand/store"; + +// Type definitions for project and material data +interface Project { + id: string; + name: string; + createdAt: string; + itemCount: number; +} + +interface Material { + id: string; + name: string; + quantity: number; + productId: string; + product: { + id: string; + title: string; + price: number; + mainImage: string; + }; +} + +export default function ProjectsPage() { + // State management for projects and UI + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [editingProject, setEditingProject] = useState(null); + const [editName, setEditName] = useState(""); + + // Authentication and routing hooks + const { data: session } = useSession(); + const router = useRouter(); + + // Cart management from global store + const { clearCart, addToCart, calculateTotals } = useProductStore(); + + // Fetch projects on component mount + useEffect(() => { + const fetchProjects = async () => { + try { + setLoading(true); + const response = await fetch("/api/contractor/projects"); + const data = await response.json(); + setProjects(data); + } catch (error) { + console.error("Error fetching projects:", error); + } finally { + setLoading(false); + } + }; + + fetchProjects(); + }, []); + + // Handle project name editing + const handleEdit = async (projectId: string) => { + if (!editName.trim()) { + toast.error("Project name cannot be empty"); + return; + } + + try { + const response = await fetch(`/api/contractor/projects/${projectId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: editName }), + }); + + if (!response.ok) { + throw new Error("Failed to update project"); + } + + // Update local state with new project name + setProjects(projects.map(project => + project.id === projectId + ? { ...project, name: editName } + : project + )); + setEditingProject(null); + setEditName(""); + toast.success("Project updated successfully"); + } catch (error) { + console.error("Error updating project:", error); + toast.error("Failed to update project"); + } + }; + + // Handle project deletion + const handleDelete = async (projectId: string) => { + if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) { + return; + } + + try { + const response = await fetch(`/api/contractor/projects/${projectId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete project"); + } + + // Remove deleted project from local state + setProjects(projects.filter(project => project.id !== projectId)); + toast.success("Project deleted successfully"); + } catch (error) { + console.error("Error deleting project:", error); + toast.error("Failed to delete project"); + } + }; + + // Handle checkout process for project materials + const handleCheckout = async (projectId: string) => { + try { + // Clear existing cart before adding new items + clearCart(); + + // Fetch materials for the project + const response = await fetch(`/api/contractor/projects/${projectId}/materials`); + if (!response.ok) { + throw new Error("Failed to fetch materials"); + } + + const materials = await response.json(); + if (!materials || materials.length === 0) { + toast.error("No materials to checkout"); + return; + } + + // Transform materials and add to cart + materials.forEach((material: any) => { + if (material.product && material.quantity > 0) { + addToCart({ + id: material.product.id, + title: material.product.title, + price: material.product.price, + image: material.product.mainImage, + amount: material.quantity + }); + } + }); + + // Update cart totals and redirect to cart page + calculateTotals(); + router.push("/cart"); + toast.success("Materials added to cart"); + } catch (error) { + console.error("Error during checkout:", error); + toast.error("Failed to process checkout"); + } + }; + + // Loading state display + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+
+

Projects

+

+ A list of all your construction projects and their materials. +

+
+ {projects.length > 0 && ( +
+ + Start New Project + +
+ )} +
+ + {projects.length === 0 ? ( +
+ +

No projects

+

+ No projects uploaded yet. Start by uploading your construction documents. +

+
+ + Upload Your First Project + +
+
+ ) : ( +
+
+
+
+ + + + + + + + + + + {projects.map((project) => ( + + + + + + + ))} + +
+ Project Name + + Created Date + + Number of Items + + Actions +
+ {editingProject === project.id ? ( +
+ setEditName(e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Project name" + /> + + + +
+ ) : ( +
+ + {project.name} + + + +
+ )} +
+ {new Date(project.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + {project.itemCount} items + + + View Materials + + {project.itemCount > 0 && ( + + )} +
+
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/projects/upload/page.tsx b/app/projects/upload/page.tsx new file mode 100644 index 0000000..6fc8c01 --- /dev/null +++ b/app/projects/upload/page.tsx @@ -0,0 +1,264 @@ +// ********************* +// Role: Project Upload Page +// Purpose: Creates new projects and handles initial file upload +// Features: +// - Project name input +// - File upload interface with progress and validation +// - Form validation +// - Automatic navigation to materials page after creation +// ********************* + +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; + +export default function UploadProjectPage() { + const router = useRouter(); + const [projectName, setProjectName] = useState(""); + const [loading, setLoading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadMessage, setUploadMessage] = useState(""); + const [selectedFiles, setSelectedFiles] = useState([]); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + setUploadProgress(0); + setUploadMessage(""); + + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + + // Validate all files + const invalidFiles = newFiles.filter(file => + file.type !== "application/pdf" || file.size > 20 * 1024 * 1024 + ); + + if (invalidFiles.length > 0) { + toast.error("Some files are invalid. Only PDF files up to 20MB are allowed."); + return; + } + + setSelectedFiles(newFiles); + setUploadMessage(`${newFiles.length} file(s) selected`); + } + }; + + const removeFile = (index: number) => { + setSelectedFiles(prevFiles => { + const newFiles = [...prevFiles]; + newFiles.splice(index, 1); + + // Update the upload message with the new count + if (newFiles.length === 0) { + setUploadMessage(""); + } else { + setUploadMessage(`${newFiles.length} file(s) selected`); + } + + return newFiles; + }); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFilesUpload = async (files: File[], projectId: string) => { + const uploadedFiles = []; + let progress = 0; + + for (const file of files) { + const formData = new FormData(); + formData.append("files", file); // This matches the backend's expected field name + + try { + const uploadResponse = await fetch(`/api/contractor/projects/${projectId}/files`, { + method: "POST", + body: formData, + credentials: 'include', // Include cookies for authentication + }); + + if (!uploadResponse.ok) { + throw new Error(`Failed to upload file: ${file.name}`); + } + + const uploadData = await uploadResponse.json(); + uploadedFiles.push(uploadData); + + // Update progress + progress = Math.round(((uploadedFiles.length) / files.length) * 100); + setUploadProgress(progress); + + } catch (error) { + console.error("Error uploading file:", error); + toast.error(`Failed to upload ${file.name}`); + } + } + + if (uploadedFiles.length > 0) { + setUploadMessage(`${uploadedFiles.length} of ${files.length} files uploaded successfully.`); + return uploadedFiles; + } + + return null; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + // First create the project + const createResponse = await fetch("/api/contractor/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: 'include', // Include cookies for authentication + body: JSON.stringify({ + name: projectName, + }), + }); + + if (!createResponse.ok) { + throw new Error("Failed to create project"); + } + + const projectData = await createResponse.json(); + + // Then upload files if any + if (selectedFiles.length > 0) { + const uploadedFiles = await handleFilesUpload(selectedFiles, projectData.id); + if (!uploadedFiles || uploadedFiles.length === 0) { + toast.error("Failed to upload files"); + } + } + + toast.success("Project created successfully!"); + router.push(`/projects/${projectData.id}/materials`); + } catch (error) { + console.error("Error creating project:", error); + toast.error("Failed to create project. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Upload Project +

+
+
+ +
+ setProjectName(e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Enter project name" + /> +
+
+ +
+ +
+ + + {selectedFiles.length > 0 + ? `${selectedFiles.length} file(s)` + : "No file chosen"} + +
+ + {selectedFiles.length > 0 && ( +
+
+ {selectedFiles.map((file, index) => ( +
+
+ {file.name} +
+ +
+ ))} +
+
+ )} + + {uploadProgress > 0 && ( +
+
+
+
+

+ Upload Progress: {uploadProgress}% +

+
+ )} + {uploadMessage && !selectedFiles.length && ( +

{uploadMessage}

+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/components/Header.tsx b/components/Header.tsx index b50cdf3..706684a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,11 +1,7 @@ // ********************* -// Role of the component: Header component -// Name of the component: Header.tsx -// Developer: Aleksandar Kuzmanovic -// Version: 1.0 -// Component call:
-// Input parameters: no input parameters -// Output: Header component +// Role of the component: Main Header component with navigation and search +// Contains: Brand logo, search bar, navigation links, and cart/wishlist icons +// Layout: Responsive flex layout that adjusts for mobile and desktop views // ********************* "use client"; @@ -16,7 +12,6 @@ import Image from "next/image"; import SearchInput from "./SearchInput"; import Link from "next/link"; import { FaBell } from "react-icons/fa6"; - import CartElement from "./CartElement"; import HeartElement from "./HeartElement"; import { signOut, useSession } from "next-auth/react"; @@ -75,17 +70,42 @@ const Header = () => {
{pathname.startsWith("/admin") === false && ( + // Main navigation container - adjusts layout for different screen sizes
+ {/* Brand logo/text section */} - singitronic logo +
+ HardwareDirect +
- -
+ + {/* Search bar section - responsive width for better layout */} +
+ +
+ + {/* Navigation and icons section */} +
+ {/* Main navigation links - hidden on mobile */} +
+ + Home + + + Upload + + + My Projects + +
+ {/* Cart and wishlist icons */}
)} + + {/* Admin header section - shown only on admin pages */} {pathname.startsWith("/admin") === true && (
diff --git a/package-lock.json b/package-lock.json index 89c67d4..554b375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.0.1", "daisyui": "^4.7.2", "eslint": "^8", @@ -1920,6 +1921,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", diff --git a/package.json b/package.json index b4acfba..382674a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.0.1", "daisyui": "^4.7.2", "eslint": "^8", diff --git a/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql new file mode 100644 index 0000000..8a5ec66 --- /dev/null +++ b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + INDEX `Project_contractorId_fkey`(`contractorId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE `ProjectProduct` ( + `id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + + INDEX `ProjectProduct_projectId_fkey`(`projectId`), + INDEX `ProjectProduct_productId_fkey`(`productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250429000001_add_project_file_table/migration.sql b/prisma/migrations/20250429000001_add_project_file_table/migration.sql new file mode 100644 index 0000000..97534e7 --- /dev/null +++ b/prisma/migrations/20250429000001_add_project_file_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE `projectfile` ( + `id` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `originalname` VARCHAR(255) NOT NULL, + `mimetype` VARCHAR(100) NOT NULL, + `size` BIGINT NOT NULL, + `path` VARCHAR(500) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `projectfile_projectId_idx` ON `projectfile`(`projectId`); + +-- AddForeignKey +ALTER TABLE `projectfile` ADD CONSTRAINT `projectfile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `electronics_shop`.`Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250430173330_add_project_file_table/migration.sql b/prisma/migrations/20250430173330_add_project_file_table/migration.sql new file mode 100644 index 0000000..ceb1cd1 --- /dev/null +++ b/prisma/migrations/20250430173330_add_project_file_table/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the `projectfile` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `projectfile` DROP FOREIGN KEY `projectfile_projectId_fkey`; + +-- DropTable +DROP TABLE `projectfile`; + +-- CreateTable +CREATE TABLE `ProjectFile` ( + `id` VARCHAR(191) NOT NULL, + `filename` VARCHAR(191) NOT NULL, + `originalname` VARCHAR(191) NOT NULL, + `mimetype` VARCHAR(191) NOT NULL, + `size` INTEGER NOT NULL, + `path` VARCHAR(191) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `projectId` VARCHAR(191) NOT NULL, + + INDEX `ProjectFile_projectId_fkey`(`projectId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectFile` ADD CONSTRAINT `ProjectFile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cff4598..5f25ac1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -25,8 +19,11 @@ model Product { inStock Int @default(1) categoryId String category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - customerOrders customer_order_product[] + projects ProjectProduct[] Wishlist Wishlist[] + customerOrders customer_order_product[] + + @@index([categoryId], map: "Product_categoryId_fkey") } model Image { @@ -35,16 +32,73 @@ model Image { image String } -model User { - id String @id @default(uuid()) - email String @unique - password String? - role String? @default("user") - Wishlist Wishlist[] +model customer_order_product { + id String @id @default(uuid()) + customerOrderId String + productId String + quantity Int + product Product @relation(fields: [productId], references: [id]) + + @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") + @@index([productId], map: "customer_order_product_productId_fkey") +} + +model Category { + id String @id @default(uuid()) + name String @unique + products Product[] +} + +model Wishlist { + id String @id @default(uuid()) + productId String + userId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@index([productId], map: "Wishlist_productId_fkey") + @@index([userId], map: "Wishlist_userId_fkey") +} + +model Project { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + itemCount Int @default(0) + contractorId String + products ProjectProduct[] + files ProjectFile[] + + @@index([contractorId], map: "Project_contractorId_fkey") } -model Customer_order { - id String @id @default(uuid()) +model ProjectProduct { + id String @id @default(uuid()) + quantity Int + projectId String + productId String + product Product @relation(fields: [productId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectProduct_projectId_fkey") + @@index([productId], map: "ProjectProduct_productId_fkey") +} + +model ProjectFile { + id String @id @default(uuid()) + filename String + originalname String + mimetype String + size Int + path String + uploadedAt DateTime @default(now()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectFile_projectId_fkey") +} + +model customer_order { + id String @id name String lastname String phone String @@ -53,34 +107,17 @@ model Customer_order { adress String apartment String postalCode String - dateTime DateTime? @default(now()) + dateTime DateTime? @default(now()) status String + total Int city String country String orderNotice String? - total Int - products customer_order_product[] } -model customer_order_product { - id String @id @default(uuid()) - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id]) - customerOrderId String - product Product @relation(fields: [productId], references: [id]) - productId String - quantity Int -} - -model Category { - id String @id @default(uuid()) - name String @unique - products Product[] // Define a one-to-many relationship -} - -model Wishlist { - id String @id @default(uuid()) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String +model user { + id String @id + email String @unique(map: "User_email_key") + password String? + role String? @default("user") } diff --git a/scripts/seed-products.ts b/scripts/seed-products.ts new file mode 100644 index 0000000..13bc7e9 --- /dev/null +++ b/scripts/seed-products.ts @@ -0,0 +1,118 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // First, create a category for electronic components if it doesn't exist + const category = await prisma.category.upsert({ + where: { name: 'Electronic Components' }, + update: {}, + create: { + name: 'Electronic Components', + }, + }); + + // Sample electronic components + const products = [ + { + title: 'Arduino Uno R3', + description: 'Microcontroller board based on the ATmega328P', + price: 2499, // $24.99 + inStock: 50, + mainImage: '/images/products/arduino-uno.jpg', + slug: 'arduino-uno-r3', + manufacturer: 'Arduino', + categoryId: category.id, + }, + { + title: 'Raspberry Pi 4 Model B (4GB)', + description: 'Single-board computer with 4GB RAM', + price: 4999, // $49.99 + inStock: 30, + mainImage: '/images/products/raspberry-pi-4.jpg', + slug: 'raspberry-pi-4-model-b-4gb', + manufacturer: 'Raspberry Pi', + categoryId: category.id, + }, + { + title: 'Breadboard 830 Points', + description: 'Solderless prototype board with 830 tie points', + price: 799, // $7.99 + inStock: 100, + mainImage: '/images/products/breadboard.jpg', + slug: 'breadboard-830-points', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'LED Kit (100pcs)', + description: 'Assorted color 5mm LEDs kit', + price: 999, // $9.99 + inStock: 200, + mainImage: '/images/products/led-kit.jpg', + slug: 'led-kit-100pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'Resistor Kit (600pcs)', + description: '1/4W resistors kit with various values', + price: 1299, // $12.99 + inStock: 150, + mainImage: '/images/products/resistor-kit.jpg', + slug: 'resistor-kit-600pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'ESP32 Development Board', + description: 'WiFi & Bluetooth enabled microcontroller board', + price: 1999, // $19.99 + inStock: 75, + mainImage: '/images/products/esp32.jpg', + slug: 'esp32-development-board', + manufacturer: 'Espressif', + categoryId: category.id, + }, + { + title: 'Jumper Wires Kit (120pcs)', + description: 'Male-to-male, male-to-female, and female-to-female jumper wires', + price: 899, // $8.99 + inStock: 100, + mainImage: '/images/products/jumper-wires.jpg', + slug: 'jumper-wires-kit-120pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'Digital Multimeter', + description: 'Auto-ranging digital multimeter with backlight', + price: 2999, // $29.99 + inStock: 40, + mainImage: '/images/products/multimeter.jpg', + slug: 'digital-multimeter', + manufacturer: 'Generic', + categoryId: category.id, + }, + ]; + + // Create all products + for (const product of products) { + await prisma.product.upsert({ + where: { slug: product.slug }, + update: product, + create: product, + }); + } + + console.log('Sample products have been added to the database'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/server/app.js b/server/app.js index 7ee21d2..bca11bc 100644 --- a/server/app.js +++ b/server/app.js @@ -1,6 +1,8 @@ const express = require("express"); const bcrypt = require('bcryptjs'); const fileUpload = require("express-fileupload"); +const path = require('path'); +const fs = require('fs'); const productsRouter = require("./routes/products"); const productImagesRouter = require("./routes/productImages"); const categoryRouter = require("./routes/category"); @@ -11,10 +13,18 @@ const orderRouter = require("./routes/customer_orders"); const slugRouter = require("./routes/slugs"); const orderProductRouter = require('./routes/customer_order_product'); const wishlistRouter = require('./routes/wishlist'); +const projectsRouter = require('./routes/projects'); +const uploadsRouter = require('./routes/uploads'); var cors = require("cors"); const app = express(); +// Ensure uploads directory exists at server startup +const uploadsDir = path.join(process.cwd(), 'server', 'uploads'); +fs.promises.mkdir(uploadsDir, { recursive: true }) + .then(() => console.log('Uploads directory created/verified')) + .catch(err => console.error('Error creating uploads directory:', err)); + app.use(express.json()); app.use( cors({ @@ -23,7 +33,15 @@ app.use( allowedHeaders: ["Content-Type", "Authorization"], }) ); -app.use(fileUpload()); +app.use(fileUpload({ + createParentPath: true, + limits: { + fileSize: 20 * 1024 * 1024 // 20MB max file size + }, +})); + +// Serve uploaded files statically +app.use('/uploads', express.static(path.join(process.cwd(), 'server', 'uploads'))); app.use("/api/products", productsRouter); app.use("/api/categories", categoryRouter); @@ -35,7 +53,8 @@ app.use("/api/orders", orderRouter); app.use('/api/order-product', orderProductRouter); app.use("/api/slugs", slugRouter); app.use("/api/wishlist", wishlistRouter); - +app.use("/api/projects", projectsRouter); +app.use("/api/uploads", uploadsRouter); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { diff --git a/server/controllers/projects.js b/server/controllers/projects.js new file mode 100644 index 0000000..d8f7e58 --- /dev/null +++ b/server/controllers/projects.js @@ -0,0 +1,506 @@ +const { PrismaClient } = require('@prisma/client'); +const fs = require('fs'); +const path = require('path'); +const prisma = new PrismaClient(); + +// Helper function to ensure upload directory exists +const ensureUploadDirExists = async (projectId) => { + // Create main uploads directory if it doesn't exist + const mainUploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(mainUploadDir, { recursive: true }); + + // Create project-specific directory + const projectUploadDir = path.join(mainUploadDir, projectId); + await fs.promises.mkdir(projectUploadDir, { recursive: true }); + + return projectUploadDir; +}; + +// Get all projects for the authenticated user +const getAllProjects = async (req, res) => { + try { + // Get user ID from request (authentication will be handled by middleware) + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const projects = await prisma.project.findMany({ + where: { contractorId: userId }, + orderBy: { createdAt: 'desc' }, + }); + + res.status(200).json(projects); + } catch (error) { + console.error('Error getting projects:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get a specific project +const getProject = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const project = await prisma.project.findUnique({ + where: { id }, + include: { + products: { + include: { + product: true, + } + } + } + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Check if user owns the project + if (project.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + res.status(200).json(project); + } catch (error) { + console.error('Error getting project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Create a new project with optional file uploads +const createProject = async (req, res) => { + try { + const { name } = req.body; + const userId = req.user?.id; + + if (!name) { + return res.status(400).json({ message: 'Project name is required' }); + } + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Create the project + const project = await prisma.project.create({ + data: { + name, + contractorId: userId, + }, + }); + + // Handle file uploads if any + if (req.files) { + const uploadDir = await ensureUploadDirExists(project.id); + const files = Array.isArray(req.files.files) ? req.files.files : [req.files.files]; + + for (const file of files) { + const fileExtension = path.extname(file.name); + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', project.id, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + // Move file to project directory + await file.mv(fullPath); + + // Create ProjectFile record + await prisma.projectFile.create({ + data: { + projectId: project.id, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + } + } + + // Return project with files + const projectWithFiles = await prisma.project.findUnique({ + where: { id: project.id }, + include: { + files: true + } + }); + + res.status(201).json(projectWithFiles); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Update a project +const updateProject = async (req, res) => { + try { + const { id } = req.params; + const { name } = req.body; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Update the project + const project = await prisma.project.update({ + where: { id }, + data: { name }, + }); + + res.status(200).json(project); + } catch (error) { + console.error('Error updating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Delete a project +const deleteProject = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Delete the project (this will cascade delete projectfiles due to the foreign key constraint) + await prisma.project.delete({ + where: { id }, + }); + + // Remove associated files from filesystem + const uploadDir = path.join(process.cwd(), 'server', 'uploads', id); + if (fs.existsSync(uploadDir)) { + fs.rmSync(uploadDir, { recursive: true, force: true }); + } + + res.status(200).json({ message: 'Project deleted successfully' }); + } catch (error) { + console.error('Error deleting project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Helper function to handle file uploads +const handleFileUploads = async (files, projectId) => { + const uploadDir = await ensureUploadDirExists(projectId); + const uploadedFiles = []; + + // Handle multiple files in an array + if (Array.isArray(files.files)) { + for (const file of files.files) { + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', projectId, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + await file.mv(fullPath); + + // Save file information in projectfile table + const projectFile = await prisma.projectFile.create({ + data: { + projectId: projectId, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + + uploadedFiles.push(projectFile); + } + } + // Handle single file upload + else if (files.files) { + const file = files.files; + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', projectId, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + await file.mv(fullPath); + + // Save file information in projectfile table + const projectFile = await prisma.projectFile.create({ + data: { + projectId: projectId, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + + uploadedFiles.push(projectFile); + } + + return uploadedFiles; +}; + +// Upload files for a project +const uploadFiles = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Check if files are provided + if (!req.files || Object.keys(req.files).length === 0) { + return res.status(400).json({ message: 'No files uploaded' }); + } + + // Handle the file uploads + const uploadedFiles = await handleFileUploads(req.files, id); + + // For each file, create a placeholder ProjectProduct + // Note: In a real implementation, you might want to parse the files and create products accordingly + for (const file of uploadedFiles) { + await prisma.projectProduct.create({ + data: { + projectId: id, + productId: "placeholder-id", // You would need to determine the actual product ID + quantity: 1, + }, + }); + } + + res.status(200).json({ message: 'Files uploaded successfully', files: uploadedFiles }); + } catch (error) { + console.error('Error uploading files:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get uploaded files for a project +const getProjectFiles = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Get files from projectfile table + const files = await prisma.projectFile.findMany({ + where: { + projectId: id + }, + orderBy: { + uploadedAt: 'desc' + } + }); + + res.status(200).json(files); + } catch (error) { + console.error('Error getting project files:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Create a project file record +const createProjectFile = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + const { filename, originalname, path, mimetype, size } = req.body; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Create file record + const file = await prisma.projectFile.create({ + data: { + projectId: id, + filename, + originalname, + path, + mimetype, + size, + } + }); + + res.status(201).json(file); + } catch (error) { + console.error('Error creating project file:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Upload materials from a PDF file +const uploadMaterialFromFile = async (req, res) => { + try { + const { projectId, filePath } = req.body; + + if (!projectId || !filePath) { + return res.status(400).json({ message: 'Project ID and file path are required' }); + } + + // Verify the project exists and belongs to the user + const existingProject = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== req.user?.id) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), 'server', filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return res.status(404).json({ message: 'File not found' }); + } + + // In a real implementation, you would parse the PDF and extract materials + // For now, we'll create a placeholder material + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "default-product-id", // You would need to determine the actual product ID + quantity: 1, + }, + }); + + // Update the project's item count + await prisma.project.update({ + where: { id: projectId }, + data: { itemCount: { increment: 1 } }, + }); + + return res.status(200).json({ message: 'Material uploaded successfully', material: newMaterial }); + } catch (error) { + console.error('Error uploading material from file:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get all uploaded files +const getUploadedFiles = async (req, res) => { + try { + // Create uploads directory if it doesn't exist + const uploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Read the directory + const files = await fs.promises.readdir(uploadDir) + .catch(error => { + console.error(`Error reading directory: ${error.message}`); + return []; // Return empty array if directory can't be read + }); + + // Filter for PDF files + const pdfFiles = files + .filter(file => file.endsWith('.pdf')) + .map(file => ({ + name: file, + path: `uploads/${file}` + })); + + return res.status(200).json(pdfFiles); + } catch (error) { + console.error('Error getting uploaded files:', error); + // Return empty array instead of error + return res.status(200).json([]); + } +}; + +module.exports = { + getAllProjects, + getProject, + createProject, + updateProject, + deleteProject, + uploadFiles, + getProjectFiles, + createProjectFile, + uploadMaterialFromFile, + getUploadedFiles +}; \ No newline at end of file diff --git a/server/controllers/uploads.js b/server/controllers/uploads.js new file mode 100644 index 0000000..be2615e --- /dev/null +++ b/server/controllers/uploads.js @@ -0,0 +1,78 @@ +const fs = require('fs'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +// Ensure the uploads directory exists +const ensureUploadDirExists = async () => { + const uploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(uploadDir, { recursive: true }); + return uploadDir; +}; + +// Upload a new file +const uploadFile = async (req, res) => { + try { + if (!req.files || Object.keys(req.files).length === 0) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + const uploadedFile = req.files.uploadedFile; + + // Validate file type + if (uploadedFile.mimetype !== 'application/pdf') { + return res.status(400).json({ message: 'Only PDF files are allowed' }); + } + + // Create uploads directory if it doesn't exist + const uploadDir = await ensureUploadDirExists(); + + // Create a unique filename + const uniqueFileName = `${uuidv4()}-${uploadedFile.name}`; + const filePath = path.join(uploadDir, uniqueFileName); + + // Move the file to the uploads directory + await uploadedFile.mv(filePath); + + return res.status(200).json({ + filePath: `uploads/${uniqueFileName}`, + message: 'File uploaded successfully' + }); + } catch (error) { + console.error('Error uploading file:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get all uploaded files +const getUploadedFiles = async (req, res) => { + try { + // Create uploads directory if it doesn't exist + const uploadDir = await ensureUploadDirExists(); + + // Check if directory exists before reading + const files = await fs.promises.readdir(uploadDir) + .catch(error => { + console.error(`Error reading directory: ${error.message}`); + return []; // Return empty array if directory can't be read + }); + + // Filter for PDF files + const pdfFiles = files + .filter(file => file.endsWith('.pdf')) + .map(file => ({ + name: file.substring(file.indexOf('-') + 1), // Remove UUID prefix + path: `uploads/${file}` + })); + + return res.status(200).json(pdfFiles); + } catch (error) { + console.error('Error getting uploaded files:', error); + // Return empty array instead of error + return res.status(200).json([]); + } +}; + +module.exports = { + uploadFile, + getUploadedFiles +}; \ No newline at end of file diff --git a/server/migration.sql b/server/migration.sql new file mode 100644 index 0000000..daa8ebe --- /dev/null +++ b/server/migration.sql @@ -0,0 +1,122 @@ +-- CreateTable +CREATE TABLE `User` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NULL, + `role` VARCHAR(191) NULL DEFAULT 'user', + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Category` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `Category_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Product` ( + `id` VARCHAR(191) NOT NULL, + `title` VARCHAR(191) NOT NULL, + `price` INTEGER NOT NULL, + `rating` INTEGER NOT NULL DEFAULT 0, + `description` TEXT NOT NULL, + `mainImage` VARCHAR(191) NULL, + `slug` VARCHAR(191) NOT NULL, + `manufacturer` VARCHAR(191) NULL, + `inStock` INTEGER NOT NULL DEFAULT 0, + `categoryId` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `Product_slug_key`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Customer_order` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `phone` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `company` VARCHAR(191) NOT NULL, + `adress` VARCHAR(191) NOT NULL, + `apartment` VARCHAR(191) NOT NULL, + `postalCode` VARCHAR(191) NOT NULL, + `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + `status` VARCHAR(191) NOT NULL, + `city` VARCHAR(191) NOT NULL, + `country` VARCHAR(191) NOT NULL, + `orderNotice` VARCHAR(191) NULL, + `total` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `customer_order_product` ( + `id` VARCHAR(191) NOT NULL, + `customerOrderId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Wishlist` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ProjectProduct` ( + `id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/server/package-lock.json b/server/package-lock.json index 26634f8..58989ca 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,7 +15,8 @@ "express": "^4.18.3", "mysql": "^2.18.1", "nanoid": "^5.0.6", - "prisma": "^5.12.1" + "prisma": "^5.12.1", + "uuid": "^11.1.0" } }, "node_modules/@prisma/client": { @@ -889,6 +890,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/server/package.json b/server/package.json index 4c9d124..b711c5b 100644 --- a/server/package.json +++ b/server/package.json @@ -2,9 +2,10 @@ "name": "razvoj_veb_aplikacija", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "start": "nodemon app.js" }, "keywords": [], "author": "", @@ -16,6 +17,7 @@ "express": "^4.18.3", "mysql": "^2.18.1", "nanoid": "^5.0.6", - "prisma": "^5.12.1" + "prisma": "^5.12.1", + "uuid": "^11.1.0" } } diff --git a/server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql b/server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql new file mode 100644 index 0000000..8a5ec66 --- /dev/null +++ b/server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + INDEX `Project_contractorId_fkey`(`contractorId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE `ProjectProduct` ( + `id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + + INDEX `ProjectProduct_projectId_fkey`(`projectId`), + INDEX `ProjectProduct_productId_fkey`(`productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql b/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql new file mode 100644 index 0000000..ceb1cd1 --- /dev/null +++ b/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the `projectfile` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `projectfile` DROP FOREIGN KEY `projectfile_projectId_fkey`; + +-- DropTable +DROP TABLE `projectfile`; + +-- CreateTable +CREATE TABLE `ProjectFile` ( + `id` VARCHAR(191) NOT NULL, + `filename` VARCHAR(191) NOT NULL, + `originalname` VARCHAR(191) NOT NULL, + `mimetype` VARCHAR(191) NOT NULL, + `size` INTEGER NOT NULL, + `path` VARCHAR(191) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `projectId` VARCHAR(191) NOT NULL, + + INDEX `ProjectFile_projectId_fkey`(`projectId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectFile` ADD CONSTRAINT `ProjectFile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index cff4598..5f25ac1 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -25,8 +19,11 @@ model Product { inStock Int @default(1) categoryId String category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - customerOrders customer_order_product[] + projects ProjectProduct[] Wishlist Wishlist[] + customerOrders customer_order_product[] + + @@index([categoryId], map: "Product_categoryId_fkey") } model Image { @@ -35,16 +32,73 @@ model Image { image String } -model User { - id String @id @default(uuid()) - email String @unique - password String? - role String? @default("user") - Wishlist Wishlist[] +model customer_order_product { + id String @id @default(uuid()) + customerOrderId String + productId String + quantity Int + product Product @relation(fields: [productId], references: [id]) + + @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") + @@index([productId], map: "customer_order_product_productId_fkey") +} + +model Category { + id String @id @default(uuid()) + name String @unique + products Product[] +} + +model Wishlist { + id String @id @default(uuid()) + productId String + userId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@index([productId], map: "Wishlist_productId_fkey") + @@index([userId], map: "Wishlist_userId_fkey") +} + +model Project { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + itemCount Int @default(0) + contractorId String + products ProjectProduct[] + files ProjectFile[] + + @@index([contractorId], map: "Project_contractorId_fkey") } -model Customer_order { - id String @id @default(uuid()) +model ProjectProduct { + id String @id @default(uuid()) + quantity Int + projectId String + productId String + product Product @relation(fields: [productId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectProduct_projectId_fkey") + @@index([productId], map: "ProjectProduct_productId_fkey") +} + +model ProjectFile { + id String @id @default(uuid()) + filename String + originalname String + mimetype String + size Int + path String + uploadedAt DateTime @default(now()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectFile_projectId_fkey") +} + +model customer_order { + id String @id name String lastname String phone String @@ -53,34 +107,17 @@ model Customer_order { adress String apartment String postalCode String - dateTime DateTime? @default(now()) + dateTime DateTime? @default(now()) status String + total Int city String country String orderNotice String? - total Int - products customer_order_product[] } -model customer_order_product { - id String @id @default(uuid()) - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id]) - customerOrderId String - product Product @relation(fields: [productId], references: [id]) - productId String - quantity Int -} - -model Category { - id String @id @default(uuid()) - name String @unique - products Product[] // Define a one-to-many relationship -} - -model Wishlist { - id String @id @default(uuid()) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String +model user { + id String @id + email String @unique(map: "User_email_key") + password String? + role String? @default("user") } diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..634d10e --- /dev/null +++ b/server/routes/projects.js @@ -0,0 +1,343 @@ +const express = require('express'); +const router = express.Router(); +const projectController = require('../controllers/projects'); +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const path = require('path'); +const fs = require('fs'); + +// Get all projects for the current user +router.get('/', projectController.getAllProjects); + +// Create a new project +router.post('/', async (req, res) => { + try { + const { name } = req.body; + const userId = req.user?.id; + + if (!name) { + return res.status(400).json({ message: 'Project name is required' }); + } + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Create the project + const project = await prisma.project.create({ + data: { + name, + contractorId: userId, + }, + }); + + res.status(201).json(project); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Get all uploaded files (no project context) +router.get('/uploaded-files', projectController.getUploadedFiles); + +// Upload material from a PDF file +router.post('/upload-material', projectController.uploadMaterialFromFile); + +// Get a specific project +router.get('/:id', projectController.getProject); + +// Update a project +router.put('/:id', projectController.updateProject); + +// Delete a project +router.delete('/:id', projectController.deleteProject); + +// Upload files for a project +router.post('/:id/files', projectController.uploadFiles); + +// Get uploaded files for a project +router.get('/:id/files', projectController.getProjectFiles); + +// Materials endpoints +// Get all materials for a project +router.get('/:id/materials', async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Verify the project exists and belongs to the user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Note: We're not checking project ownership here for simplicity + + // Fetch materials with associated product data + const materials = await prisma.projectProduct.findMany({ + where: { + projectId: id, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Transform the data to include name property + const transformedMaterials = materials.map((material) => ({ + ...material, + name: material.product?.title || "Unknown Material", + unit: "piece", // Default unit + })); + + res.status(200).json(transformedMaterials); + } catch (error) { + console.error("Error fetching materials:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Add a new material to a project +router.post('/:id/materials', async (req, res) => { + try { + const { id } = req.params; + const { productId, quantity } = req.body; + + // Validate required fields + if (!productId || !quantity) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + // Verify product exists + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + // Create new project product entry + const material = await prisma.projectProduct.create({ + data: { + projectId: id, + productId, + quantity, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Add name property to response + const transformedMaterial = { + ...material, + name: material.product.title, + }; + + // Update project item count + await prisma.project.update({ + where: { id }, + data: { itemCount: { increment: 1 } }, + }); + + res.status(201).json(transformedMaterial); + } catch (error) { + console.error("Error creating material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Update a material +router.put('/:id/materials/:materialId', async (req, res) => { + try { + const { id, materialId } = req.params; + const { quantity } = req.body; + + // Update the material + const updatedMaterial = await prisma.projectProduct.update({ + where: { + id: materialId, + projectId: id, + }, + data: { + quantity, + }, + include: { + product: true, + }, + }); + + // Transform to match expected format + const material = { + id: updatedMaterial.id, + name: updatedMaterial.product.title, + description: updatedMaterial.product.description, + quantity: updatedMaterial.quantity, + unit: "piece", + productId: updatedMaterial.product.id + }; + + res.status(200).json(material); + } catch (error) { + console.error("Error updating material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Delete a material +router.delete('/:id/materials/:materialId', async (req, res) => { + try { + const { id, materialId } = req.params; + + // First verify that the project exists and get current itemCount + const project = await prisma.project.findUnique({ + where: { + id, + }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Use a transaction to ensure both operations succeed or fail together + await prisma.$transaction([ + prisma.projectProduct.delete({ + where: { + id: materialId, + projectId: id, + }, + }), + prisma.project.update({ + where: { + id, + }, + data: { + // Ensure itemCount never goes below 0 + itemCount: Math.max(0, (project.itemCount || 0) - 1), + }, + }), + ]); + + res.status(204).send(); + } catch (error) { + console.error("Error deleting material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Recalculate project item count +router.post('/:id/recalculate', async (req, res) => { + try { + const { id } = req.params; + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id, + }, + include: { + products: true, + }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Count the actual number of materials + const actualCount = project.products.length; + + // Update the project with the correct count + const updatedProject = await prisma.project.update({ + where: { + id, + }, + data: { + itemCount: actualCount, + }, + }); + + res.status(200).json({ itemCount: updatedProject.itemCount }); + } catch (error) { + console.error("Error recalculating project:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Delete a project file +router.delete('/:id/files/:fileId', async (req, res) => { + try { + const { id, fileId } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Verify project exists and belongs to user + const project = await prisma.project.findUnique({ + where: { id }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (project.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Get file details + const file = await prisma.projectFile.findUnique({ + where: { id: fileId }, + }); + + if (!file || file.projectId !== id) { + return res.status(404).json({ message: 'File not found' }); + } + + // Delete file from filesystem + const fullPath = path.join(process.cwd(), 'server', file.path); + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + } + + // Delete file record from database + await prisma.projectFile.delete({ + where: { id: fileId }, + }); + + res.status(200).json({ message: 'File deleted successfully' }); + } catch (error) { + console.error('Error deleting file:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/server/routes/uploads.js b/server/routes/uploads.js new file mode 100644 index 0000000..d9b3f22 --- /dev/null +++ b/server/routes/uploads.js @@ -0,0 +1,11 @@ +const express = require('express'); +const router = express.Router(); +const uploadsController = require('../controllers/uploads'); + +// Upload a new file +router.post('/', uploadsController.uploadFile); + +// Get all uploaded files +router.get('/', uploadsController.getUploadedFiles); + +module.exports = router; \ No newline at end of file diff --git a/server/utills/db.ts b/server/utills/db.ts index 36be3ac..6990dac 100644 --- a/server/utills/db.ts +++ b/server/utills/db.ts @@ -1,18 +1,11 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; -const prismaClientSingleton = () => { - return new PrismaClient(); -} +const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL + }, + }, +}); -type PrismaClientSingleton = ReturnType; - -const globalForPrisma = globalThis as unknown as { - prisma: PrismaClientSingleton | undefined; -} - -const prisma = globalForPrisma.prisma ?? prismaClientSingleton(); - - -export default prisma; - -if(process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; \ No newline at end of file +export default prisma; \ No newline at end of file