From da7038e051bbc430dcc42894bc2497efc7d1e0d4 Mon Sep 17 00:00:00 2001 From: Gitead Date: Mon, 27 Apr 2026 00:29:11 -0400 Subject: [PATCH] feat: cloud storage updates, claims storage, appointment and insurance routes --- apps/Backend/src/routes/appointments.ts | 39 +++--- apps/Backend/src/routes/cloud-storage.ts | 22 ---- apps/Backend/src/routes/insuranceStatus.ts | 16 ++- apps/Backend/src/storage/claims-storage.ts | 12 ++ .../src/storage/cloudStorage-storage.ts | 123 +++++++++++------- .../src/redux/slices/seleniumTaskSlice.ts | 3 +- packages/db/prisma/seed.ts | 17 ++- packages/db/types/claim-types.ts | 1 + 8 files changed, 138 insertions(+), 95 deletions(-) diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts index 2dd8ae0..91bb04a 100755 --- a/apps/Backend/src/routes/appointments.ts +++ b/apps/Backend/src/routes/appointments.ts @@ -186,31 +186,29 @@ router.post( return res.status(404).json({ message: "Patient not found" }); } - // 2. Attempt to find the next available slot + // 2. One patient per column per day: find existing appointment for this patient in the same staff column today + const existingPatientAppointment = await storage.getPatientAppointmentByDateAndStaff( + appointmentData.patientId, + appointmentData.date, + appointmentData.staffId + ); + + // 3. Attempt to find the next available slot let [hour, minute] = originalStartTime.split(":").map(Number); const pad = (n: number) => n.toString().padStart(2, "0"); - // Step by 15 minutes to support quarter-hour starts, but keep appointment duration 30 mins const STEP_MINUTES = 15; const APPT_DURATION_MINUTES = 30; while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) { const currentStartTime = `${pad(hour)}:${pad(minute)}`; - // Check patient appointment at this time - const sameDayAppointment = - await storage.getPatientAppointmentByDateTime( - appointmentData.patientId, - appointmentData.date, - currentStartTime - ); - - // Check staff conflict at this time + // Check staff conflict at this time (exclude the patient's existing appointment so it can move) const staffConflict = await storage.getStaffAppointmentByDateTime( appointmentData.staffId, appointmentData.date, currentStartTime, - sameDayAppointment?.id // Ignore self if updating + existingPatientAppointment?.id ); if (!staffConflict) { @@ -226,14 +224,13 @@ router.post( endTime: currentEndTime, }; - let responseData; - - if (sameDayAppointment?.id !== undefined) { + if (existingPatientAppointment?.id !== undefined) { + // Replace the existing appointment in-place (preserves linked claims/procedures) const updated = await storage.updateAppointment( - sameDayAppointment.id, + existingPatientAppointment.id, payload ); - responseData = { + return res.status(200).json({ ...updated, originalRequestedTime: originalStartTime, finalScheduledTime: currentStartTime, @@ -241,12 +238,11 @@ router.post( originalStartTime !== currentStartTime ? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.` : `Appointment successfully updated at ${currentStartTime}.`, - }; - return res.status(200).json(responseData); + }); } const created = await storage.createAppointment(payload); - responseData = { + return res.status(201).json({ ...created, originalRequestedTime: originalStartTime, finalScheduledTime: currentStartTime, @@ -254,8 +250,7 @@ router.post( originalStartTime !== currentStartTime ? `Your requested time (${originalStartTime}) was unavailable. Appointment was scheduled at ${currentStartTime}.` : `Appointment successfully scheduled at ${currentStartTime}.`, - }; - return res.status(201).json(responseData); + }); } // Move to next STEP_MINUTES slot diff --git a/apps/Backend/src/routes/cloud-storage.ts b/apps/Backend/src/routes/cloud-storage.ts index 856539b..1723adb 100755 --- a/apps/Backend/src/routes/cloud-storage.ts +++ b/apps/Backend/src/routes/cloud-storage.ts @@ -351,28 +351,6 @@ router.post( return sendError(res, 400, "Invalid file id"); try { - // Ask storage for the file (includes chunks in your implementation) - const file = await storage.getFile(id); - if (!file) return sendError(res, 404, "File not found"); - - // Sum chunks' sizes (storage.getFile returns chunks ordered by seq in your impl) - const chunks = (file as any).chunks ?? []; - if (!chunks.length) return sendError(res, 400, "No chunks uploaded"); - - let total = 0; - for (const c of chunks) { - // c.data is Bytes / Buffer-like - total += c.data.length; - // early bailout - if (total > MAX_FILE_BYTES) { - return sendError( - res, - 413, - `Assembled file is too large (${Math.round(total / 1024 / 1024)} MB). Max allowed is ${MAX_FILE_MB} MB.` - ); - } - } - const result = await storage.finalizeFileUpload(id); return res.json({ error: false, data: result }); } catch (err: any) { diff --git a/apps/Backend/src/routes/insuranceStatus.ts b/apps/Backend/src/routes/insuranceStatus.ts index 4b891bc..481cfb4 100755 --- a/apps/Backend/src/routes/insuranceStatus.ts +++ b/apps/Backend/src/routes/insuranceStatus.ts @@ -215,7 +215,7 @@ router.post( router.post( "/appointments/check-all-eligibilities", async (req: Request, res: Response): Promise => { - // Query param: date=YYYY-MM-DD (required) + // Query param: date=YYYY-MM-DD (required), staffIds=1,2,3 (optional) const date = String(req.query.date ?? "").trim(); if (!date) { return res @@ -223,6 +223,11 @@ router.post( .json({ error: "Missing date query param (YYYY-MM-DD)" }); } + const staffIdsRaw = String(req.query.staffIds ?? "").trim(); + const staffIdFilter: Set | null = staffIdsRaw + ? new Set(staffIdsRaw.split(",").map(Number).filter((n) => !isNaN(n) && n > 0)) + : null; + if (!req.user || !req.user.id) { return res.status(401).json({ error: "Unauthorized: user info missing" }); } @@ -232,16 +237,21 @@ router.post( try { // 1) fetch appointments for the day (reuse your storage API) - const dayAppointments = await storage.getAppointmentsByDateForUser( + const allDayAppointments = await storage.getAppointmentsByDateForUser( date, req.user.id ); - if (!Array.isArray(dayAppointments)) { + if (!Array.isArray(allDayAppointments)) { return res .status(500) .json({ error: "Failed to load appointments for date" }); } + // Filter by selected staff columns if provided + const dayAppointments = staffIdFilter && staffIdFilter.size > 0 + ? allDayAppointments.filter((a) => staffIdFilter.has(Number(a.staffId))) + : allDayAppointments; + const results: Array = []; // process sequentially so selenium agent / python semaphore isn't overwhelmed diff --git a/apps/Backend/src/storage/claims-storage.ts b/apps/Backend/src/storage/claims-storage.ts index 2a0cc29..ebf3615 100755 --- a/apps/Backend/src/storage/claims-storage.ts +++ b/apps/Backend/src/storage/claims-storage.ts @@ -9,6 +9,7 @@ import { prisma as db } from "@repo/db/client"; export interface IStorage { getClaim(id: number): Promise; + getActiveClaimByAppointmentId(appointmentId: number): Promise; getRecentClaimsByPatientId( patientId: number, limit: number, @@ -54,6 +55,17 @@ export const claimsStorage: IStorage = { }); }, + async getActiveClaimByAppointmentId(appointmentId: number): Promise { + return db.claim.findFirst({ + where: { + appointmentId, + status: { notIn: ["CANCELLED", "VOID"] }, + }, + orderBy: { createdAt: "desc" }, + include: { serviceLines: true, claimFiles: true, staff: true }, + }) as Promise; + }, + async getClaimsByAppointmentId(appointmentId: number): Promise { return await db.claim.findMany({ where: { appointmentId } }); }, diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index 53061d2..0455683 100755 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -1,6 +1,23 @@ 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 cloudFolderDir(folderId: number | null): string { + const dir = path.join(CLOUD_ROOT, folderId != null ? `folder-${folderId}` : "root"); + 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 @@ -266,42 +283,47 @@ export const cloudStorageStorage: IStorage = { }, async appendFileChunk(fileId: number, seq: number, data: Buffer) { - try { - await db.cloudFileChunk.create({ data: { fileId, seq, data } }); - } catch (err: any) { - // idempotent: ignore duplicate chunk constraint - if ( - err?.code === "P2002" || - err?.message?.includes("Unique constraint failed") - ) { - return; - } - throw err; - } + 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 chunks = await db.cloudFileChunk.findMany({ where: { fileId } }); - if (!chunks.length) throw new Error("No chunks uploaded"); - - // compute total size - let total = 0; - for (const c of chunks) total += c.data.length; - - // transactionally update file and read folderId - const updated = await db.$transaction(async (tx) => { - await tx.cloudFile.update({ - where: { id: fileId }, - data: { fileSize: BigInt(total), isComplete: true }, - }); - return tx.cloudFile.findUnique({ - where: { id: fileId }, - select: { folderId: true }, - }); + const file = await db.cloudFile.findUnique({ + where: { id: fileId }, + select: { name: true, folderId: true }, }); + if (!file) throw new Error("File record not found"); - const folderId = (updated as any)?.folderId ?? null; - await updateFolderTimestampsRecursively(folderId); + 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); + 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() }; }, @@ -310,10 +332,17 @@ export const cloudStorageStorage: IStorage = { try { const file = await db.cloudFile.findUnique({ where: { id: fileId }, - select: { folderId: true }, + 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; @@ -473,20 +502,22 @@ export const cloudStorageStorage: IStorage = { // --- STREAM --- async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { - const batchSize = 100; - let offset = 0; - while (true) { - const chunks = await db.cloudFileChunk.findMany({ - where: { fileId }, - orderBy: { seq: "asc" }, - take: batchSize, - skip: offset, - }); - if (!chunks.length) break; - for (const c of chunks) resStream.write(Buffer.from(c.data)); - offset += chunks.length; - if (chunks.length < batchSize) break; - } + 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 }); + }); }, }; diff --git a/apps/Frontend/src/redux/slices/seleniumTaskSlice.ts b/apps/Frontend/src/redux/slices/seleniumTaskSlice.ts index 07e401d..2c15640 100644 --- a/apps/Frontend/src/redux/slices/seleniumTaskSlice.ts +++ b/apps/Frontend/src/redux/slices/seleniumTaskSlice.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; export type TaskStatus = "idle" | "pending" | "success" | "error"; -export type TaskKey = "claimSubmit" | "eligibilityCheck" | "eligibilityBatchCheck"; +export type TaskKey = "claimSubmit" | "eligibilityCheck" | "eligibilityBatchCheck" | "claimBatchCheck"; export interface SeleniumTaskState { status: TaskStatus; @@ -15,6 +15,7 @@ const initialState: Record = { claimSubmit: { ...emptyTask }, eligibilityCheck: { ...emptyTask }, eligibilityBatchCheck: { ...emptyTask }, + claimBatchCheck: { ...emptyTask }, }; const seleniumTaskSlice = createSlice({ diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index d287319..04b8ddf 100755 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -12,13 +12,28 @@ const prisma = new PrismaClient({ adapter } as any); async function main() { const hashedPassword = await bcrypt.hash("123456", 10); - await prisma.user.upsert({ + const admin = await prisma.user.upsert({ where: { username: "admin" }, update: {}, create: { username: "admin", password: hashedPassword }, }); console.log("Seed complete: admin user created (username: admin, password: 123456)"); + + // Seed 5 default staff members — rename these to real staff names in Settings + const defaultStaff = ["A", "B", "C", "D", "E"]; + for (const name of defaultStaff) { + const existing = await prisma.staff.findFirst({ + where: { userId: admin.id, name }, + }); + if (!existing) { + await prisma.staff.create({ + data: { userId: admin.id, name, role: "Staff" }, + }); + } + } + + console.log("Seed complete: 5 default staff members created (A, B, C, D, E)"); } main() diff --git a/packages/db/types/claim-types.ts b/packages/db/types/claim-types.ts index 16d26ce..02bfe6f 100755 --- a/packages/db/types/claim-types.ts +++ b/packages/db/types/claim-types.ts @@ -50,6 +50,7 @@ export type ClaimFileMeta = { id?: number; filename: string; mimeType?: string | null; + filePath?: string; }; //used in claim-form