feat: cloud storage updates, claims storage, appointment and insurance routes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user