added file upload and storage
This commit is contained in:
85
app/(auth)/testing/page.tsx
Normal file
85
app/(auth)/testing/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, DragEvent } from 'react'
|
||||||
|
|
||||||
|
export default function UploadTestPage() {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0] || null
|
||||||
|
setSelectedFile(file)
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">File Upload Test</h2>
|
||||||
|
|
||||||
|
{/* Drag and drop area */}
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={e => 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 ? (
|
||||||
|
<p>{selectedFile.name}</p>
|
||||||
|
) : (
|
||||||
|
<p>Drag & drop a file here or click below to select</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File input */}
|
||||||
|
<input type="file" onChange={handleFileChange} className="block" />
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!selectedFile || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Upload result */}
|
||||||
|
{message && <p className="text-sm">{message}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
app/api/files/upload/route.ts
Normal file
86
app/api/files/upload/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# app:
|
||||||
|
# volumes:
|
||||||
|
# - ./data/uploads:/app/data/uploads
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
||||||
|
|||||||
5
lib/auth/checkRole.ts
Normal file
5
lib/auth/checkRole.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Role } from '@prisma/client'
|
||||||
|
|
||||||
|
export function canUploadOrViewFiles(role: Role | undefined | null): boolean {
|
||||||
|
return role === 'COUPLE' || role === 'PLANNER'
|
||||||
|
}
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -52,6 +52,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.74"
|
"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": {
|
"node_modules/next-themes": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
@@ -8243,10 +8253,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"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",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.74"
|
"zod": "^3.25.74"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +16,8 @@ model User {
|
|||||||
role Role @default(GUEST)
|
role Role @default(GUEST)
|
||||||
events Event[] @relation("EventCreator")
|
events Event[] @relation("EventCreator")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
FileUpload FileUpload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
@@ -37,6 +39,8 @@ model Event {
|
|||||||
notes String?
|
notes String?
|
||||||
todos EventTodo[]
|
todos EventTodo[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
FileUpload FileUpload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Location {
|
model Location {
|
||||||
@@ -119,3 +123,18 @@ model EventTodo {
|
|||||||
// category String?
|
// category String?
|
||||||
// assignedTo String? // could link to User in future
|
// 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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user