diff --git a/app/(auth)/testing/page.tsx b/app/(auth)/testing/page.tsx new file mode 100644 index 0000000..c5e75c1 --- /dev/null +++ b/app/(auth)/testing/page.tsx @@ -0,0 +1,85 @@ +'use client' + +import React, { useState, DragEvent } from 'react' + +export default function UploadTestPage() { + const [selectedFile, setSelectedFile] = useState(null) + const [uploading, setUploading] = useState(false) + const [message, setMessage] = useState('') + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] || null + setSelectedFile(file) + setMessage('') + } + + const handleDrop = (e: DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files?.[0] + if (file) { + setSelectedFile(file) + setMessage('') + } + } + + const handleUpload = async () => { + if (!selectedFile) return + + const formData = new FormData() + formData.append('file', selectedFile) + + setUploading(true) + setMessage('') + + try { + const res = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }) + + const data = await res.json() + if (!res.ok) throw new Error(data.message || 'Upload failed') + + setMessage(`✅ File uploaded: ${data.filename || selectedFile.name}`) + setSelectedFile(null) + } catch (err: any) { + setMessage(`❌ Upload failed: ${err.message}`) + } finally { + setUploading(false) + } + } + + return ( +
+

File Upload Test

+ + {/* Drag and drop area */} +
e.preventDefault()} + className="border-2 border-dashed border-gray-400 rounded p-6 text-center cursor-pointer bg-gray-50 hover:bg-gray-100" + > + {selectedFile ? ( +

{selectedFile.name}

+ ) : ( +

Drag & drop a file here or click below to select

+ )} +
+ + {/* File input */} + + + {/* Upload button */} + + + {/* Upload result */} + {message &&

{message}

} +
+ ) +} diff --git a/app/api/files/upload/route.ts b/app/api/files/upload/route.ts new file mode 100644 index 0000000..f4cdfc5 --- /dev/null +++ b/app/api/files/upload/route.ts @@ -0,0 +1,86 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { canUploadOrViewFiles } from "@/lib/auth/checkRole"; +import { v4 as uuidv4 } from 'uuid'; +import path from "path"; +import { mkdirSync, existsSync, writeFileSync } from 'fs'; +import { prisma } from "@/lib/prisma"; + +const MAX_SIZE_MB = parseInt(process.env.UPLOAD_FILE_SIZE || '10', 10); +const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024; + +// Ensure it's relative to project root +const UPLOAD_DIR = path.join(process.cwd(), 'data/uploads'); + +export const config = { + api: { + bodyParser: false, + }, +}; + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + const user = session?.user; + + if (!user || !canUploadOrViewFiles(user.role)) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + try { + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const eventId = formData.get("eventId") as string | null; + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + + if (file.size > MAX_SIZE_BYTES) { + return NextResponse.json({ error: `File exceeds ${MAX_SIZE_MB}MB` }, { status: 400 }); + } + + const allowedTypes = ["application/pdf", "image/jpeg", "image/png", "text/plain"]; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json({ error: "Unsupported file type" }, { status: 400 }); + } + + // Ensure uploads directory exists + if (!existsSync(UPLOAD_DIR)) { + mkdirSync(UPLOAD_DIR, { recursive: true }); + } + + // Create unique filename + const safeFileName = `${uuidv4()}-${file.name}`; + const fullPath = path.join(UPLOAD_DIR, safeFileName); + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + writeFileSync(fullPath, buffer); + + // Save metadata in DB + const saved = await prisma.fileUpload.create({ + data: { + filename: file.name, + filepath: fullPath, + filetype: file.type, + filesize: file.size, + uploadedBy: { + connect: { email: user.email! }, + }, + event: eventId ? { + connect: { id: eventId } + } : undefined, + }, + }); + + return NextResponse.json({ + message: "File uploaded successfully", + file: saved, + }, { status: 201 }); + + } catch (error) { + console.error("File upload error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 96dc538..bbda952 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: volumes: - ./data/postgres:/var/lib/postgresql/data + # app: + # volumes: + # - ./data/uploads:/app/data/uploads + volumes: pgdata: diff --git a/lib/auth/checkRole.ts b/lib/auth/checkRole.ts new file mode 100644 index 0000000..749b353 --- /dev/null +++ b/lib/auth/checkRole.ts @@ -0,0 +1,5 @@ +import { Role } from '@prisma/client' + +export function canUploadOrViewFiles(role: Role | undefined | null): boolean { + return role === 'COUPLE' || role === 'PLANNER' +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cc19298..3bd3881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "remark-gfm": "^4.0.1", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0", "vaul": "^1.1.2", "zod": "^3.25.74" }, @@ -6508,6 +6509,15 @@ } } }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -8243,10 +8253,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "8.3.2", + "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" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/vaul": { diff --git a/package.json b/package.json index 8c47d79..86f27d5 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "remark-gfm": "^4.0.1", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0", "vaul": "^1.1.2", "zod": "^3.25.74" }, diff --git a/prisma/migrations/20250711170415_add_file_upload_model/migration.sql b/prisma/migrations/20250711170415_add_file_upload_model/migration.sql new file mode 100644 index 0000000..62eb767 --- /dev/null +++ b/prisma/migrations/20250711170415_add_file_upload_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "FileUpload" ( + "id" TEXT NOT NULL, + "filepath" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "filetype" TEXT NOT NULL, + "filesize" INTEGER NOT NULL, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "uploadedById" TEXT NOT NULL, + "eventId" TEXT, + + CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FileUpload_filename_uploadedById_key" ON "FileUpload"("filename", "uploadedById"); + +-- AddForeignKey +ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FileUpload" ADD CONSTRAINT "FileUpload_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed23dd7..62daf70 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,8 @@ model User { role Role @default(GUEST) events Event[] @relation("EventCreator") createdAt DateTime @default(now()) + + FileUpload FileUpload[] } enum Role { @@ -37,6 +39,8 @@ model Event { notes String? todos EventTodo[] createdAt DateTime @default(now()) + + FileUpload FileUpload[] } model Location { @@ -119,3 +123,18 @@ model EventTodo { // category String? // assignedTo String? // could link to User in future } + +model FileUpload { + id String @id @default(cuid()) + filepath String + filename String + filetype String + filesize Int //in bytes + uploadedAt DateTime @default(now()) + uploadedBy User @relation(fields: [uploadedById], references: [id]) + uploadedById String + event Event? @relation(fields: [eventId], references: [id]) + eventId String? + + @@unique([filename, uploadedById]) +}