feat: add per-patient Local folder in Documents page backed by Cloud Storage

- Documents page shows a "Local Folder" card for each selected patient
  with an "Open in Cloud Storage" button that deep-links to their folder
- Cloud Storage page reads ?folderId URL param on mount and auto-opens
  the folder panel for seamless navigation from Documents
- Backend: GET /api/cloud-storage/patient-folder/:patientId endpoint
  that idempotently gets or creates a top-level CloudFolder per patient
- CloudFolder schema gains optional patientId field linked to Patient
- Disk directories for cloud storage folders now use the folder's name
  (e.g. "Xiaohui Wang/") instead of the opaque "folder-{id}/" path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-05 23:01:56 -04:00
parent 2457e12b5c
commit d5bc96ff39
158 changed files with 1539 additions and 130 deletions

View File

@@ -7,8 +7,20 @@ import path from "path";
const CLOUD_ROOT = path.join(process.cwd(), "uploads", "cloud-storage");
const CLOUD_TMP = path.join(CLOUD_ROOT, "tmp");
function cloudFolderDir(folderId: number | null): string {
const dir = path.join(CLOUD_ROOT, folderId != null ? `folder-${folderId}` : "root");
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;
}
@@ -124,6 +136,13 @@ export interface IStorage {
// Streaming
streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise<void>;
// Patient folder
getOrCreatePatientFolder(
userId: number,
patientId: number,
patientName: string
): Promise<CloudFolder>;
}
/* ------------------------------- Implementation ------------------------------- */
@@ -295,6 +314,16 @@ export const cloudStorageStorage: IStorage = {
});
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()
@@ -302,7 +331,7 @@ export const cloudStorageStorage: IStorage = {
if (!chunkFiles.length) throw new Error("No chunks uploaded");
// Assemble chunks into final file
const destDir = cloudFolderDir(file.folderId);
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");
@@ -500,6 +529,23 @@ export const cloudStorageStorage: IStorage = {
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;
},
// --- STREAM ---
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
const file = await db.cloudFile.findUnique({