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

@@ -2,6 +2,7 @@ import express, { Request, Response } from "express";
import storage from "../storage";
import { serializeFile } from "../utils/prismaFileUtils";
import { CloudFolder } from "@repo/db/types";
import { prisma as db } from "@repo/db/client";
const router = express.Router();
@@ -133,6 +134,35 @@ router.get(
}
);
// ---------- Patient-dedicated folder (get or create) ----------
// GET /patient-folder/:patientId?userId=XXX
router.get(
"/patient-folder/:patientId",
async (req: Request, res: Response): Promise<any> => {
const patientId = Number.parseInt(req.params.patientId ?? "", 10);
const userId = Number.parseInt(String(req.query.userId ?? ""), 10);
if (!Number.isInteger(patientId) || patientId <= 0)
return sendError(res, 400, "Invalid patientId");
if (!Number.isInteger(userId) || userId <= 0)
return sendError(res, 400, "Missing or invalid userId");
try {
const patient = await db.patient.findUnique({
where: { id: patientId },
select: { firstName: true, lastName: true },
});
if (!patient) return sendError(res, 404, "Patient not found");
const patientName = `${patient.firstName} ${patient.lastName}`.trim();
const folder = await storage.getOrCreatePatientFolder(userId, patientId, patientName);
return res.json({ error: false, data: folder });
} catch (err) {
return sendError(res, 500, "Failed to get or create patient folder", err);
}
}
);
// ---------- Folder CRUD ----------
router.get(
"/folders/:id",

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({