- PDF viewer hung on "Loading" forever for cloud-backed PDFs: streamFileTo
pipes with {end: false} (by design, so cloud-storage.ts's own routes can
call res.end() themselves), but the new /pdf-files/:id cloud branch never
did, so the HTTP response never completed.
- Documents page showed nothing for patients whose PDFs are only in Cloud
Storage: the page lists categories from the legacy PdfGroup table, which
new saves stopped populating. savePdfToCloudStorage now also ensures a
blob-less PdfGroup row exists per patient/category so the category still
appears, with actual files served from Cloud Storage via the existing
merge in the pdf-files/group listing routes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
688 lines
20 KiB
TypeScript
Executable File
688 lines
20 KiB
TypeScript
Executable File
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<CloudFolder | null>;
|
|
listRecentFolders(
|
|
limit: number,
|
|
offset: number,
|
|
parentId?: number | null
|
|
): Promise<CloudFolder[]>;
|
|
countFoldersByParent(parentId: number | null): Promise<number>;
|
|
countFolders(filter?: {
|
|
userId?: number;
|
|
nameContains?: string | null;
|
|
}): Promise<number>;
|
|
createFolder(
|
|
userId: number,
|
|
name: string,
|
|
parentId?: number | null
|
|
): Promise<CloudFolder>;
|
|
updateFolder(
|
|
id: number,
|
|
updates: Partial<{ name?: string; parentId?: number | null }>
|
|
): Promise<CloudFolder | null>;
|
|
deleteFolder(id: number): Promise<boolean>;
|
|
|
|
// Files
|
|
getFile(id: number): Promise<CloudFile | null>;
|
|
listFilesInFolder(
|
|
folderId: number | null,
|
|
limit: number,
|
|
offset: number
|
|
): Promise<CloudFile[]>;
|
|
initializeFileUpload(
|
|
userId: number,
|
|
name: string,
|
|
mimeType?: string | null,
|
|
expectedSize?: bigint | null,
|
|
totalChunks?: number | null,
|
|
folderId?: number | null
|
|
): Promise<CloudFile>;
|
|
appendFileChunk(fileId: number, seq: number, data: Buffer): Promise<void>;
|
|
finalizeFileUpload(fileId: number): Promise<{ ok: true; size: string; diskPath: string }>;
|
|
deleteFile(fileId: number): Promise<boolean>;
|
|
updateFile(
|
|
id: number,
|
|
updates: Partial<Pick<CloudFile, "name" | "mimeType" | "folderId">>
|
|
): Promise<CloudFile | null>;
|
|
renameFile(id: number, name: string): Promise<CloudFile | null>;
|
|
countFilesInFolder(folderId: number | null): Promise<number>;
|
|
countFiles(filter?: {
|
|
userId?: number;
|
|
nameContains?: string | null;
|
|
mimeType?: string | null;
|
|
}): Promise<number>;
|
|
|
|
// 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<void>;
|
|
|
|
// Patient folder
|
|
getOrCreatePatientFolder(
|
|
userId: number,
|
|
patientId: number,
|
|
patientName: string
|
|
): Promise<CloudFolder>;
|
|
getOrCreateSubfolder(
|
|
userId: number,
|
|
parentId: number,
|
|
name: string
|
|
): Promise<CloudFolder>;
|
|
savePdfToCloudStorage(
|
|
userId: number,
|
|
patientId: number,
|
|
patientName: string,
|
|
category: "Eligibility" | "Claims" | "PreAuth" | "Claim Status" | "Attachments",
|
|
filename: string,
|
|
buffer: Buffer,
|
|
mimeType?: string
|
|
): Promise<CloudFile>;
|
|
listPatientCategoryCloudFiles(
|
|
patientId: number,
|
|
category: string
|
|
): Promise<CloudFile[]>;
|
|
}
|
|
|
|
/* ------------------------------- 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<Pick<CloudFile, "name" | "mimeType" | "folderId">>
|
|
) {
|
|
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<string, { titleKey: string; title: string }> = {
|
|
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<void>((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;
|