feat: cloud storage updates, claims storage, appointment and insurance routes

This commit is contained in:
Gitead
2026-04-27 00:29:11 -04:00
parent 3e899376c3
commit da7038e051
8 changed files with 138 additions and 95 deletions

View File

@@ -186,31 +186,29 @@ router.post(
return res.status(404).json({ message: "Patient not found" }); 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); let [hour, minute] = originalStartTime.split(":").map(Number);
const pad = (n: number) => n.toString().padStart(2, "0"); 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 STEP_MINUTES = 15;
const APPT_DURATION_MINUTES = 30; const APPT_DURATION_MINUTES = 30;
while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) { while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
const currentStartTime = `${pad(hour)}:${pad(minute)}`; const currentStartTime = `${pad(hour)}:${pad(minute)}`;
// Check patient appointment at this time // Check staff conflict at this time (exclude the patient's existing appointment so it can move)
const sameDayAppointment =
await storage.getPatientAppointmentByDateTime(
appointmentData.patientId,
appointmentData.date,
currentStartTime
);
// Check staff conflict at this time
const staffConflict = await storage.getStaffAppointmentByDateTime( const staffConflict = await storage.getStaffAppointmentByDateTime(
appointmentData.staffId, appointmentData.staffId,
appointmentData.date, appointmentData.date,
currentStartTime, currentStartTime,
sameDayAppointment?.id // Ignore self if updating existingPatientAppointment?.id
); );
if (!staffConflict) { if (!staffConflict) {
@@ -226,14 +224,13 @@ router.post(
endTime: currentEndTime, endTime: currentEndTime,
}; };
let responseData; if (existingPatientAppointment?.id !== undefined) {
// Replace the existing appointment in-place (preserves linked claims/procedures)
if (sameDayAppointment?.id !== undefined) {
const updated = await storage.updateAppointment( const updated = await storage.updateAppointment(
sameDayAppointment.id, existingPatientAppointment.id,
payload payload
); );
responseData = { return res.status(200).json({
...updated, ...updated,
originalRequestedTime: originalStartTime, originalRequestedTime: originalStartTime,
finalScheduledTime: currentStartTime, finalScheduledTime: currentStartTime,
@@ -241,12 +238,11 @@ router.post(
originalStartTime !== currentStartTime originalStartTime !== currentStartTime
? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.` ? `Your requested time (${originalStartTime}) was unavailable. Appointment was updated to ${currentStartTime}.`
: `Appointment successfully updated at ${currentStartTime}.`, : `Appointment successfully updated at ${currentStartTime}.`,
}; });
return res.status(200).json(responseData);
} }
const created = await storage.createAppointment(payload); const created = await storage.createAppointment(payload);
responseData = { return res.status(201).json({
...created, ...created,
originalRequestedTime: originalStartTime, originalRequestedTime: originalStartTime,
finalScheduledTime: currentStartTime, finalScheduledTime: currentStartTime,
@@ -254,8 +250,7 @@ router.post(
originalStartTime !== currentStartTime originalStartTime !== currentStartTime
? `Your requested time (${originalStartTime}) was unavailable. Appointment was scheduled at ${currentStartTime}.` ? `Your requested time (${originalStartTime}) was unavailable. Appointment was scheduled at ${currentStartTime}.`
: `Appointment successfully scheduled at ${currentStartTime}.`, : `Appointment successfully scheduled at ${currentStartTime}.`,
}; });
return res.status(201).json(responseData);
} }
// Move to next STEP_MINUTES slot // Move to next STEP_MINUTES slot

View File

@@ -351,28 +351,6 @@ router.post(
return sendError(res, 400, "Invalid file id"); return sendError(res, 400, "Invalid file id");
try { 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); const result = await storage.finalizeFileUpload(id);
return res.json({ error: false, data: result }); return res.json({ error: false, data: result });
} catch (err: any) { } catch (err: any) {

View File

@@ -215,7 +215,7 @@ router.post(
router.post( router.post(
"/appointments/check-all-eligibilities", "/appointments/check-all-eligibilities",
async (req: Request, res: Response): Promise<any> => { async (req: Request, res: Response): Promise<any> => {
// 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(); const date = String(req.query.date ?? "").trim();
if (!date) { if (!date) {
return res return res
@@ -223,6 +223,11 @@ router.post(
.json({ error: "Missing date query param (YYYY-MM-DD)" }); .json({ error: "Missing date query param (YYYY-MM-DD)" });
} }
const staffIdsRaw = String(req.query.staffIds ?? "").trim();
const staffIdFilter: Set<number> | null = staffIdsRaw
? new Set(staffIdsRaw.split(",").map(Number).filter((n) => !isNaN(n) && n > 0))
: null;
if (!req.user || !req.user.id) { if (!req.user || !req.user.id) {
return res.status(401).json({ error: "Unauthorized: user info missing" }); return res.status(401).json({ error: "Unauthorized: user info missing" });
} }
@@ -232,16 +237,21 @@ router.post(
try { try {
// 1) fetch appointments for the day (reuse your storage API) // 1) fetch appointments for the day (reuse your storage API)
const dayAppointments = await storage.getAppointmentsByDateForUser( const allDayAppointments = await storage.getAppointmentsByDateForUser(
date, date,
req.user.id req.user.id
); );
if (!Array.isArray(dayAppointments)) { if (!Array.isArray(allDayAppointments)) {
return res return res
.status(500) .status(500)
.json({ error: "Failed to load appointments for date" }); .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<any> = []; const results: Array<any> = [];
// process sequentially so selenium agent / python semaphore isn't overwhelmed // process sequentially so selenium agent / python semaphore isn't overwhelmed

View File

@@ -9,6 +9,7 @@ import { prisma as db } from "@repo/db/client";
export interface IStorage { export interface IStorage {
getClaim(id: number): Promise<Claim | undefined>; getClaim(id: number): Promise<Claim | undefined>;
getActiveClaimByAppointmentId(appointmentId: number): Promise<ClaimWithServiceLines | null>;
getRecentClaimsByPatientId( getRecentClaimsByPatientId(
patientId: number, patientId: number,
limit: number, limit: number,
@@ -54,6 +55,17 @@ export const claimsStorage: IStorage = {
}); });
}, },
async getActiveClaimByAppointmentId(appointmentId: number): Promise<ClaimWithServiceLines | null> {
return db.claim.findFirst({
where: {
appointmentId,
status: { notIn: ["CANCELLED", "VOID"] },
},
orderBy: { createdAt: "desc" },
include: { serviceLines: true, claimFiles: true, staff: true },
}) as Promise<ClaimWithServiceLines | null>;
},
async getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]> { async getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]> {
return await db.claim.findMany({ where: { appointmentId } }); return await db.claim.findMany({ where: { appointmentId } });
}, },

View File

@@ -1,6 +1,23 @@
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
import { CloudFolder, CloudFile } from "@repo/db/types"; import { CloudFolder, CloudFile } from "@repo/db/types";
import { serializeFile } from "../utils/prismaFileUtils"; 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 * Cloud storage implementation
@@ -266,42 +283,47 @@ export const cloudStorageStorage: IStorage = {
}, },
async appendFileChunk(fileId: number, seq: number, data: Buffer) { async appendFileChunk(fileId: number, seq: number, data: Buffer) {
try { const chunkPath = path.join(tmpChunkDir(fileId), `chunk-${String(seq).padStart(8, "0")}`);
await db.cloudFileChunk.create({ data: { fileId, seq, data } }); // idempotent: overwrite if already written
} catch (err: any) { fs.writeFileSync(chunkPath, data);
// idempotent: ignore duplicate chunk constraint
if (
err?.code === "P2002" ||
err?.message?.includes("Unique constraint failed")
) {
return;
}
throw err;
}
}, },
async finalizeFileUpload(fileId: number) { async finalizeFileUpload(fileId: number) {
const chunks = await db.cloudFileChunk.findMany({ where: { fileId } }); const file = await db.cloudFile.findUnique({
if (!chunks.length) throw new Error("No chunks uploaded"); where: { id: fileId },
select: { name: true, folderId: true },
// 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 },
});
}); });
if (!file) throw new Error("File record not found");
const folderId = (updated as any)?.folderId ?? null; const tmpDir = path.join(CLOUD_TMP, String(fileId));
await updateFolderTimestampsRecursively(folderId); 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() }; return { ok: true, size: BigInt(total).toString() };
}, },
@@ -310,10 +332,17 @@ export const cloudStorageStorage: IStorage = {
try { try {
const file = await db.cloudFile.findUnique({ const file = await db.cloudFile.findUnique({
where: { id: fileId }, where: { id: fileId },
select: { folderId: true }, select: { folderId: true, diskPath: true },
}); });
if (!file) return false; if (!file) return false;
const folderId = file.folderId ?? null; 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 db.cloudFile.delete({ where: { id: fileId } });
await updateFolderTimestampsRecursively(folderId); await updateFolderTimestampsRecursively(folderId);
return true; return true;
@@ -473,20 +502,22 @@ export const cloudStorageStorage: IStorage = {
// --- STREAM --- // --- STREAM ---
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
const batchSize = 100; const file = await db.cloudFile.findUnique({
let offset = 0; where: { id: fileId },
while (true) { select: { diskPath: true },
const chunks = await db.cloudFileChunk.findMany({ });
where: { fileId }, if (!file?.diskPath) throw new Error("File not found on disk");
orderBy: { seq: "asc" },
take: batchSize, const abs = path.join(process.cwd(), file.diskPath);
skip: offset, if (!fs.existsSync(abs)) throw new Error("File missing from disk");
});
if (!chunks.length) break; await new Promise<void>((resolve, reject) => {
for (const c of chunks) resStream.write(Buffer.from(c.data)); const readable = fs.createReadStream(abs);
offset += chunks.length; readable.on("error", reject);
if (chunks.length < batchSize) break; resStream.on("error", reject);
} readable.on("end", resolve);
readable.pipe(resStream, { end: false });
});
}, },
}; };

View File

@@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export type TaskStatus = "idle" | "pending" | "success" | "error"; export type TaskStatus = "idle" | "pending" | "success" | "error";
export type TaskKey = "claimSubmit" | "eligibilityCheck" | "eligibilityBatchCheck"; export type TaskKey = "claimSubmit" | "eligibilityCheck" | "eligibilityBatchCheck" | "claimBatchCheck";
export interface SeleniumTaskState { export interface SeleniumTaskState {
status: TaskStatus; status: TaskStatus;
@@ -15,6 +15,7 @@ const initialState: Record<TaskKey, SeleniumTaskState> = {
claimSubmit: { ...emptyTask }, claimSubmit: { ...emptyTask },
eligibilityCheck: { ...emptyTask }, eligibilityCheck: { ...emptyTask },
eligibilityBatchCheck: { ...emptyTask }, eligibilityBatchCheck: { ...emptyTask },
claimBatchCheck: { ...emptyTask },
}; };
const seleniumTaskSlice = createSlice({ const seleniumTaskSlice = createSlice({

View File

@@ -12,13 +12,28 @@ const prisma = new PrismaClient({ adapter } as any);
async function main() { async function main() {
const hashedPassword = await bcrypt.hash("123456", 10); const hashedPassword = await bcrypt.hash("123456", 10);
await prisma.user.upsert({ const admin = await prisma.user.upsert({
where: { username: "admin" }, where: { username: "admin" },
update: {}, update: {},
create: { username: "admin", password: hashedPassword }, create: { username: "admin", password: hashedPassword },
}); });
console.log("Seed complete: admin user created (username: admin, password: 123456)"); 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() main()

View File

@@ -50,6 +50,7 @@ export type ClaimFileMeta = {
id?: number; id?: number;
filename: string; filename: string;
mimeType?: string | null; mimeType?: string | null;
filePath?: string;
}; };
//used in claim-form //used in claim-form