import { prisma as db } from "@repo/db/client"; import { CloudFolder, CloudFile } from "@repo/db/types"; import { serializeFile } from "../utils/prismaFileUtils"; import fs from "fs"; import path from "path"; const CLOUD_ROOT = path.join(process.cwd(), "uploads", "cloud-storage"); const CLOUD_TMP = path.join(CLOUD_ROOT, "tmp"); function sanitizeDirName(name: string): string { return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "unnamed"; } function cloudFolderDir(folderId: number | null, folderName?: string): string { let dirName: string; if (folderId == null) { dirName = "root"; } else if (folderName) { dirName = sanitizeDirName(folderName); } else { dirName = `folder-${folderId}`; } const dir = path.join(CLOUD_ROOT, dirName); fs.mkdirSync(dir, { recursive: true }); return dir; } function tmpChunkDir(fileId: number): string { const dir = path.join(CLOUD_TMP, String(fileId)); fs.mkdirSync(dir, { recursive: true }); return dir; } /** * Cloud storage implementation * * - Clear, self-describing method names * - Folder timestamp propagation helper: updateFolderTimestampsRecursively * - File upload lifecycle: initializeFileUpload -> appendFileChunk -> finalizeFileUpload */ /* ------------------------------- Helpers ------------------------------- */ async function updateFolderTimestampsRecursively(folderId: number | null) { if (folderId == null) return; let currentId: number | null = folderId; const MAX_DEPTH = 50; let depth = 0; while (currentId != null && depth < MAX_DEPTH) { depth += 1; try { // touch updatedAt and fetch parentId const row = (await db.cloudFolder.update({ where: { id: currentId }, data: { updatedAt: new Date() }, select: { parentId: true }, })) as { parentId: number | null }; currentId = row.parentId ?? null; } catch (err: any) { // Stop walking if folder removed concurrently (Prisma P2025) if (err?.code === "P2025") break; throw err; } } } /* ------------------------------- IStorage ------------------------------- */ export interface IStorage { // Folders getFolder(id: number): Promise; listRecentFolders( limit: number, offset: number, parentId?: number | null ): Promise; countFoldersByParent(parentId: number | null): Promise; countFolders(filter?: { userId?: number; nameContains?: string | null; }): Promise; createFolder( userId: number, name: string, parentId?: number | null ): Promise; updateFolder( id: number, updates: Partial<{ name?: string; parentId?: number | null }> ): Promise; deleteFolder(id: number): Promise; // Files getFile(id: number): Promise; listFilesInFolder( folderId: number | null, limit: number, offset: number ): Promise; initializeFileUpload( userId: number, name: string, mimeType?: string | null, expectedSize?: bigint | null, totalChunks?: number | null, folderId?: number | null ): Promise; appendFileChunk(fileId: number, seq: number, data: Buffer): Promise; finalizeFileUpload(fileId: number): Promise<{ ok: true; size: string; diskPath: string }>; deleteFile(fileId: number): Promise; updateFile( id: number, updates: Partial> ): Promise; renameFile(id: number, name: string): Promise; countFilesInFolder(folderId: number | null): Promise; countFiles(filter?: { userId?: number; nameContains?: string | null; mimeType?: string | null; }): Promise; // Search searchFolders( q: string, limit: number, offset: number, parentId?: number | null ): Promise<{ data: CloudFolder[]; total: number }>; searchFiles( q: string, type: string | undefined, limit: number, offset: number ): Promise<{ data: CloudFile[]; total: number }>; // Streaming streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise; // Patient folder getOrCreatePatientFolder( userId: number, patientId: number, patientName: string ): Promise; getOrCreateSubfolder( userId: number, parentId: number, name: string ): Promise; savePdfToCloudStorage( userId: number, patientId: number, patientName: string, category: "Eligibility" | "Claims" | "PreAuth" | "Claim Status" | "Attachments", filename: string, buffer: Buffer, mimeType?: string ): Promise; listPatientCategoryCloudFiles( patientId: number, category: string ): Promise; } /* ------------------------------- Implementation ------------------------------- */ export const cloudStorageStorage: IStorage = { // --- FOLDERS --- async getFolder(id: number) { const folder = await db.cloudFolder.findUnique({ where: { id }, include: { files: false }, }); return (folder as unknown as CloudFolder) ?? null; }, async listRecentFolders(limit = 50, offset = 0, parentId?: number | null) { const where: any = {}; // parentId === undefined → no filter (global recent) // parentId === null → top-level folders (parent IS NULL) // parentId === number → children of that folder if (parentId !== undefined) { where.parentId = parentId; } const folders = await db.cloudFolder.findMany({ where, orderBy: { updatedAt: "desc" }, skip: offset, take: limit, }); return folders as unknown as CloudFolder[]; }, async countFoldersByParent(parentId: number | null = null) { return db.cloudFolder.count({ where: { parentId } }); }, async createFolder( userId: number, name: string, parentId: number | null = null ) { const created = await db.cloudFolder.create({ data: { userId, name, parentId }, }); // mark parent(s) as updated await updateFolderTimestampsRecursively(parentId); return created as unknown as CloudFolder; }, async updateFolder( id: number, updates: Partial<{ name?: string; parentId?: number | null }> ) { try { const updated = await db.cloudFolder.update({ where: { id }, data: updates, }); if (updates.parentId !== undefined) { await updateFolderTimestampsRecursively(updates.parentId ?? null); } else { // touch this folder's parent (to mark modification) const f = await db.cloudFolder.findUnique({ where: { id }, select: { parentId: true }, }); await updateFolderTimestampsRecursively(f?.parentId ?? null); } return updated as unknown as CloudFolder; } catch (err) { throw err; } }, async deleteFolder(id: number) { try { const folder = await db.cloudFolder.findUnique({ where: { id }, select: { parentId: true }, }); const parentId = folder?.parentId ?? null; await db.cloudFolder.delete({ where: { id } }); await updateFolderTimestampsRecursively(parentId); return true; } catch (err: any) { if (err?.code === "P2025") return false; throw err; } }, async countFolders(filter?: { userId?: number; nameContains?: string | null; }) { const where: any = {}; if (filter?.userId) where.userId = filter.userId; if (filter?.nameContains) where.name = { contains: filter.nameContains, mode: "insensitive" }; return db.cloudFolder.count({ where }); }, // --- FILES --- async getFile(id: number) { const file = await db.cloudFile.findUnique({ where: { id }, include: { chunks: { orderBy: { seq: "asc" } } }, }); return (file as unknown as CloudFile) ?? null; }, async listFilesInFolder( folderId: number | null = null, limit = 50, offset = 0 ) { const files = await db.cloudFile.findMany({ where: { folderId }, orderBy: { createdAt: "desc" }, skip: offset, take: limit, select: { id: true, name: true, mimeType: true, fileSize: true, folderId: true, isComplete: true, createdAt: true, updatedAt: true, }, }); return files.map(serializeFile) as unknown as CloudFile[]; }, async initializeFileUpload( userId: number, name: string, mimeType: string | null = null, expectedSize: bigint | null = null, totalChunks: number | null = null, folderId: number | null = null ) { const created = await db.cloudFile.create({ data: { userId, name, mimeType, fileSize: expectedSize ?? BigInt(0), folderId, totalChunks, isComplete: false, }, }); await updateFolderTimestampsRecursively(folderId); return serializeFile(created) as unknown as CloudFile; }, async appendFileChunk(fileId: number, seq: number, data: Buffer) { const chunkPath = path.join(tmpChunkDir(fileId), `chunk-${String(seq).padStart(8, "0")}`); // idempotent: overwrite if already written fs.writeFileSync(chunkPath, data); }, async finalizeFileUpload(fileId: number) { const file = await db.cloudFile.findUnique({ where: { id: fileId }, select: { name: true, folderId: true }, }); if (!file) throw new Error("File record not found"); // Look up folder name so the disk directory uses the human-readable name let folderName: string | undefined; if (file.folderId != null) { const folder = await db.cloudFolder.findUnique({ where: { id: file.folderId }, select: { name: true }, }); folderName = folder?.name; } const tmpDir = path.join(CLOUD_TMP, String(fileId)); const chunkFiles = fs.existsSync(tmpDir) ? fs.readdirSync(tmpDir).filter((f) => f.startsWith("chunk-")).sort() : []; if (!chunkFiles.length) throw new Error("No chunks uploaded"); // Assemble chunks into final file const destDir = cloudFolderDir(file.folderId, folderName); const safeName = file.name.replace(/[/\\?%*:|"<>]/g, "-"); const destPath = path.join(destDir, `${Date.now()}_${safeName}`); const out = fs.openSync(destPath, "w"); let total = 0; for (const chunk of chunkFiles) { const buf = fs.readFileSync(path.join(tmpDir, chunk)); fs.writeSync(out, buf); total += buf.length; } fs.closeSync(out); // Clean up temp chunks fs.rmSync(tmpDir, { recursive: true, force: true }); const diskPath = path.relative(process.cwd(), destPath); await db.cloudFile.update({ where: { id: fileId }, data: { fileSize: BigInt(total), isComplete: true, diskPath }, }); await updateFolderTimestampsRecursively(file.folderId); return { ok: true, size: BigInt(total).toString(), diskPath }; }, async deleteFile(fileId: number) { try { const file = await db.cloudFile.findUnique({ where: { id: fileId }, select: { folderId: true, diskPath: true }, }); if (!file) return false; const folderId = file.folderId ?? null; // Remove from disk if (file.diskPath) { const abs = path.join(process.cwd(), file.diskPath); if (fs.existsSync(abs)) fs.unlinkSync(abs); } await db.cloudFile.delete({ where: { id: fileId } }); await updateFolderTimestampsRecursively(folderId); return true; } catch (err: any) { if (err?.code === "P2025") return false; throw err; } }, async updateFile( id: number, updates: Partial> ) { try { let prevFolderId: number | null = null; if (updates.folderId !== undefined) { const f = await db.cloudFile.findUnique({ where: { id }, select: { folderId: true }, }); prevFolderId = f?.folderId ?? null; } const updated = await db.cloudFile.update({ where: { id }, data: updates, }); // touch affected folders if (updates.folderId !== undefined) { await updateFolderTimestampsRecursively(updates.folderId ?? null); if ( prevFolderId != null && prevFolderId !== (updates.folderId ?? null) ) { await updateFolderTimestampsRecursively(prevFolderId); } } else { const f = await db.cloudFile.findUnique({ where: { id }, select: { folderId: true }, }); await updateFolderTimestampsRecursively(f?.folderId ?? null); } return serializeFile(updated) as unknown as CloudFile; } catch (err) { throw err; } }, async renameFile(id: number, name: string) { try { const updated = await db.cloudFile.update({ where: { id }, data: { name }, }); const f = await db.cloudFile.findUnique({ where: { id }, select: { folderId: true }, }); await updateFolderTimestampsRecursively(f?.folderId ?? null); return serializeFile(updated) as unknown as CloudFile; } catch (err) { throw err; } }, async countFilesInFolder(folderId: number | null) { return db.cloudFile.count({ where: { folderId } }); }, async countFiles(filter?: { userId?: number; nameContains?: string | null; mimeType?: string | null; }) { const where: any = {}; if (filter?.userId) where.userId = filter.userId; if (filter?.nameContains) where.name = { contains: filter.nameContains, mode: "insensitive" }; if (filter?.mimeType) where.mimeType = { startsWith: filter.mimeType, mode: "insensitive" }; return db.cloudFile.count({ where }); }, // --- SEARCH --- async searchFolders( q: string, limit = 20, offset = 0, parentId?: number | null ) { // Build where clause const where: any = { name: { contains: q, mode: "insensitive" }, }; // If parentId is explicitly provided: // - parentId === null -> top-level folders (parent IS NULL) // - parentId === number -> children of that folder // If parentId is undefined -> search across all folders (no parent filter) if (parentId !== undefined) { where.parentId = parentId; } const [folders, total] = await Promise.all([ db.cloudFolder.findMany({ where, orderBy: { name: "asc" }, skip: offset, take: limit, }), db.cloudFolder.count({ where, }), ]); return { data: folders as unknown as CloudFolder[], total }; }, async searchFiles( q: string, type: string | undefined, limit = 20, offset = 0 ) { const where: any = {}; if (q) where.name = { contains: q, mode: "insensitive" }; if (type) { if (!type.includes("/")) where.mimeType = { startsWith: `${type}/`, mode: "insensitive" }; else where.mimeType = { startsWith: type, mode: "insensitive" }; } const [files, total] = await Promise.all([ db.cloudFile.findMany({ where, orderBy: { createdAt: "desc" }, skip: offset, take: limit, select: { id: true, name: true, mimeType: true, fileSize: true, folderId: true, isComplete: true, createdAt: true, updatedAt: true, }, }), db.cloudFile.count({ where }), ]); return { data: files.map(serializeFile) as unknown as CloudFile[], total }; }, // --- PATIENT FOLDER --- async getOrCreatePatientFolder( userId: number, patientId: number, patientName: string ) { const existing = await db.cloudFolder.findFirst({ where: { patientId }, }); if (existing) return existing as unknown as CloudFolder; const created = await db.cloudFolder.create({ data: { userId, name: patientName, parentId: null, patientId }, }); return created as unknown as CloudFolder; }, async getOrCreateSubfolder(userId: number, parentId: number, name: string) { const existing = await db.cloudFolder.findFirst({ where: { parentId, name }, }); if (existing) return existing as unknown as CloudFolder; try { return await cloudStorageStorage.createFolder(userId, name, parentId); } catch (err: any) { // unique constraint on [userId, parentId, name] - another request created it first if (err?.code === "P2002") { const race = await db.cloudFolder.findFirst({ where: { parentId, name } }); if (race) return race as unknown as CloudFolder; } throw err; } }, async savePdfToCloudStorage( userId: number, patientId: number, patientName: string, category: "Eligibility" | "Claims" | "PreAuth" | "Claim Status" | "Attachments", filename: string, buffer: Buffer, mimeType: string = "application/pdf" ) { const patientFolder = await cloudStorageStorage.getOrCreatePatientFolder( userId, patientId, patientName ); const subfolder = await cloudStorageStorage.getOrCreateSubfolder( userId, patientFolder.id!, category ); const cloudFile = await cloudStorageStorage.initializeFileUpload( userId, filename, mimeType, BigInt(buffer.length), 1, subfolder.id! ); await cloudStorageStorage.appendFileChunk(cloudFile.id!, 0, buffer); await cloudStorageStorage.finalizeFileUpload(cloudFile.id!); const finalFile = await cloudStorageStorage.getFile(cloudFile.id!); if (!finalFile) throw new Error("Failed to finalize PDF upload to cloud storage"); // Ensure a (blob-less) PdfGroup row exists for this patient/category so the // Documents page — which lists categories from PdfGroup — still shows this // category even when every PDF in it lives in Cloud Storage. const CATEGORY_TO_TITLE_KEY: Record = { Eligibility: { titleKey: "ELIGIBILITY_STATUS", title: "Eligibility Status" }, Claims: { titleKey: "INSURANCE_CLAIM", title: "Claims" }, PreAuth: { titleKey: "INSURANCE_CLAIM_PREAUTH", title: "Preauth" }, "Claim Status": { titleKey: "CLAIM_STATUS", title: "Claim Status" }, Attachments: { titleKey: "OTHER", title: "Attachments" }, }; const mapping = CATEGORY_TO_TITLE_KEY[category]; if (mapping) { const existingGroup = await db.pdfGroup.findFirst({ where: { patientId, titleKey: mapping.titleKey as any }, }); if (!existingGroup) { await db.pdfGroup.create({ data: { patientId, title: mapping.title, titleKey: mapping.titleKey as any }, }); } } return finalFile; }, async listPatientCategoryCloudFiles(patientId: number, category: string) { const patientFolder = await db.cloudFolder.findFirst({ where: { patientId } }); if (!patientFolder) return []; const subfolder = await db.cloudFolder.findFirst({ where: { parentId: patientFolder.id, name: category }, }); if (!subfolder) return []; const files = await db.cloudFile.findMany({ where: { folderId: subfolder.id, isComplete: true }, orderBy: { createdAt: "desc" }, select: { id: true, name: true, mimeType: true, fileSize: true, folderId: true, isComplete: true, createdAt: true, updatedAt: true, }, }); return files.map(serializeFile) as unknown as CloudFile[]; }, // --- STREAM --- async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { const file = await db.cloudFile.findUnique({ where: { id: fileId }, select: { diskPath: true }, }); if (!file?.diskPath) throw new Error("File not found on disk"); const abs = path.join(process.cwd(), file.diskPath); if (!fs.existsSync(abs)) throw new Error("File missing from disk"); await new Promise((resolve, reject) => { const readable = fs.createReadStream(abs); readable.on("error", reject); resStream.on("error", reject); readable.on("end", resolve); readable.pipe(resStream, { end: false }); }); }, }; export default cloudStorageStorage;