Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f5795e5
Sprint 2: comment Prisma `Files` model; add Supabase upload helper
sihaosimonz Nov 20, 2025
13b13c3
Update database url config for prisma 7
sihaosimonz Nov 23, 2025
a787c0a
Add new file: src/app/api/documents/index.ts. Implement server side h…
sihaosimonz Nov 23, 2025
e04a13e
(WIP) Implement admin doc upload backend wiring. Add route + helper m…
sihaosimonz Nov 24, 2025
3ab7cdf
fallback to old url config due to env issue. to be discussed later
sihaosimonz Nov 29, 2025
f40cae3
Add admin view for existing documents from DB (currently requires pub…
sihaosimonz Nov 30, 2025
87fd2ee
Guard viewing uploaded file body in supabase with signedUrl; Polish a…
sihaosimonz Dec 5, 2025
9d6cc5f
Merge branch 'main' into feature/admin-doc-view-front-connect-backend
sihaosimonz Dec 5, 2025
1cd84cc
Tidy up format
sihaosimonz Dec 5, 2025
dd0c743
Refactor GET function in src/app/api/route.ts to use NextRequest (fix…
sihaosimonz Jan 17, 2026
6801973
Implement display user fullname instead of UUID in file cards
sihaosimonz Jan 19, 2026
46fab00
Implement file upload panel user real name auto-population/selection
sihaosimonz Jan 19, 2026
4fd52bc
refactor edit button logic in file cards to hide it from the non-uplo…
sihaosimonz Feb 11, 2026
6c598c5
connect edit file panel adding viewers action to backend
sihaosimonz Feb 11, 2026
3c7e60b
Merge branch 'main' into feature/admin-doc-view-front-connect-backend
sihaosimonz Feb 11, 2026
7ed9550
refactor UUID to fullname mapping logic to make sure the viewer fulln…
sihaosimonz Feb 11, 2026
95c2f88
implement Edit Panel auto-population dropdown for adding viewers
sihaosimonz Feb 11, 2026
36661a2
wire UUID -> fullname lookup into Edit Panel
sihaosimonz Feb 11, 2026
18bb239
wire UUID -> fullname lookup to list view (previously only icon view …
sihaosimonz Feb 11, 2026
3643296
Harden documents API auth boundaries and error handling
Feb 16, 2026
619c7ed
Allow admin and HR document deletion
Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ dist
CHANGELOG.md

supabase
!src/utils/supabase
8 changes: 7 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};

module.exports = nextConfig;
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ model Files {
userId String @db.Uuid
bucket String
path String
// type of documents
type FileTypes
uploadedAt DateTime @default(now())
// metadata for extra info for files, format:
// {
// "fileName": string, // display name in AdminDocuments
// "viewers": string[], // names/emails of allowed viewers
// "folderPath": string[] // e.g. ["Documents", "Onboarding"]
// }
metadata Json?
owner UserMetadata @relation(fields: [userId], references: [id], onDelete: Cascade)

Expand Down
158 changes: 158 additions & 0 deletions src/app/api/documents/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { FileTypes, type Files } from "@prisma/client";
import {
uploadFileToStorage,
getFileSignedUrl,
deleteFileFromStorage,
} from "@/utils/supabase/server";
import { prisma } from "@/lib/prisma";

export const DOCUMENTS_BUCKET = "files";

// Data we need from the frontend when uploading a document
export type UploadDocumentInput = {
userId: string;
fileName: string;
viewers: string[];
folderPath: string[];
contentType?: string;
fileBody: ArrayBuffer; // plain array of bytes of file
};

// data returned to the frontend after upload
export type UploadedDocumentResult = {
id: string;
bucket: string;
path: string;
fileName: string;
viewers: string[];
folderPath: string[];
uploadedAt: string;
};

type FileWithMetadataViewers = Files & {
metadata?: {
viewers?: string[];
} | null;
};

export class DocumentAccessError extends Error {
status: number;

constructor(message: string, status: number) {
super(message);
this.name = "DocumentAccessError";
this.status = status;
}
}

// server function handling upload

export async function uploadDocumentCore(
input: UploadDocumentInput,
): Promise<UploadedDocumentResult> {
const { userId, fileName, viewers, folderPath, contentType, fileBody } = input;

// build storage path inside the bucket
const safeFileName = fileName.replace(/\s+/g, "_"); // replace spaces
const timestamp = Date.now(); // timestamp gives uniqueness to filepaths
const path = `${userId}/${timestamp}-${safeFileName}`;

// supabase side
// upload file bytes
const { bucket: storedBucket, path: storedPath } = await uploadFileToStorage(
DOCUMENTS_BUCKET,
path,
fileBody,
contentType,
);

let created;
try {
// prisma side
// create file row in database
created = await prisma.files.create({
data: {
userId,
bucket: storedBucket,
path: storedPath,
type: FileTypes.DOCUMENT,
metadata: {
fileName,
viewers,
folderPath,
},
},
});
} catch (err) {
// Compensating action: remove object from storage when DB write fails.
try {
await deleteFileFromStorage(storedBucket, storedPath);
} catch (cleanupErr) {
console.error("Failed to rollback uploaded file after DB error", cleanupErr);
}
throw err;
}

// return clean result for API route
return {
id: created.id, // id handled by prisma
bucket: created.bucket,
path: created.path,
fileName,
viewers,
folderPath,
uploadedAt: created.uploadedAt.toISOString(), // convert time object into unique string
};
}

// helper function to check whether valid viewer or not
async function canUserViewFile(
file: FileWithMetadataViewers,
currentUserId: string,
): Promise<boolean> {
// check whether is owner
if (file.userId === currentUserId) {
return true;
}
// check whether is admin
// TODO: db access implementation may be improved, but it works for now
const userMetadata = await prisma.userMetadata.findUnique({
where: { id: currentUserId },
select: { is_admin: true },
});
if (userMetadata?.is_admin) {
return true;
}
// check whether assigned as viewer
const metadata = file.metadata as unknown as { viewers?: string[] } | null;
const viewers = metadata?.viewers;
if (Array.isArray(viewers) && viewers.includes(currentUserId)) {
return true;
}
// else not allowed to view file
return false;
}

// file id and current user id as input
// check permission & return signed url
export async function getSignedUrlForFileId(
fileId: string,
currentUserId: string,
expiresInSeconds: 60,
): Promise<string> {
// look up file in database
const file = await prisma.files.findUnique({
where: { id: fileId },
});

if (!file || file.type !== FileTypes.DOCUMENT || file.bucket !== DOCUMENTS_BUCKET) {
throw new DocumentAccessError("File not found", 404);
}
// check permission for viewing
if (!(await canUserViewFile(file, currentUserId))) {
throw new DocumentAccessError("You do not have permission to view this file", 403);
}
// if all good, return signed url
const signedUrl = await getFileSignedUrl(file.bucket, file.path, expiresInSeconds);
return signedUrl;
}
Loading