initial commit
This commit is contained in:
13
apps/Backend/.env
Executable file
13
apps/Backend/.env
Executable file
@@ -0,0 +1,13 @@
|
||||
NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
# FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
# FRONTEND_URLS=http://localhost:3000
|
||||
FRONTEND_URLS=http://192.168.1.37:3000
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST=localhost
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=mypassword
|
||||
DB_NAME=dentalapp
|
||||
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
|
||||
13
apps/Backend/.env.example
Executable file
13
apps/Backend/.env.example
Executable file
@@ -0,0 +1,13 @@
|
||||
NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
# FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
# FRONTEND_URLS=http://localhost:3000
|
||||
FRONTEND_URLS=http://192.168.1.37:3000
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST=localhost
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=mypassword
|
||||
DB_NAME=dentalapp
|
||||
DATABASE_URL=postgresql://postgres:mypassword@localhost:5433/dentalapp
|
||||
36
apps/Backend/.turbo/turbo-dev.log
Executable file
36
apps/Backend/.turbo/turbo-dev.log
Executable file
@@ -0,0 +1,36 @@
|
||||
|
||||
> backend@1.0.0 dev
|
||||
> ts-node-dev --respawn --transpile-only src/index.ts
|
||||
|
||||
[[36mINFO[0m] [30;1m11:56:33[0m ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.2, typescript ver. 5.9.3)
|
||||
✅ Server running in development mode at http://0.0.0.0:5000
|
||||
[2026-01-25T16:57:06.447Z] OPTIONS /api/notifications 204 in 3ms
|
||||
[2026-01-25T16:57:06.448Z] OPTIONS /api/users/ 204 in 0ms
|
||||
[2026-01-25T16:57:06.454Z] GET /api/notifications 403 in 5ms
|
||||
[2026-01-25T16:57:06.456Z] GET /api/users/ 403 in 1ms
|
||||
[2026-01-25T16:57:13.454Z] OPTIONS /api/auth/login 204 in 1ms
|
||||
[2026-01-25T16:57:13.676Z] POST /api/auth/login 200 in 208ms :: {"user":{"id":2,"username":"admin"},"token…
|
||||
[2026-01-25T16:57:13.700Z] OPTIONS /api/notifications 204 in 1ms
|
||||
[2026-01-25T16:57:13.715Z] GET /api/notifications 200 in 13ms :: []
|
||||
[2026-01-25T16:57:14.010Z] OPTIONS /api/patients/recent 204 in 1ms
|
||||
[2026-01-25T16:57:14.077Z] GET /api/patients/recent 200 in 60ms :: {"patients":[{"id":129,"firstName":"ROB…
|
||||
[2026-01-25T16:57:16.531Z] OPTIONS /api/database-management/destination 204 in 1ms
|
||||
[2026-01-25T16:57:16.535Z] OPTIONS /api/database-management/status 204 in 1ms
|
||||
[2026-01-25T16:57:16.563Z] GET /api/database-management/destination 200 in 24ms :: [{"id":6,"userId":2,"pa…
|
||||
[2026-01-25T16:57:16.596Z] GET /api/database-management/status 200 in 48ms :: {"connected":true,"size":"20…
|
||||
[2026-01-25T16:57:21.719Z] OPTIONS /api/patients/recent 204 in 1ms
|
||||
[2026-01-25T16:57:21.732Z] GET /api/patients/recent 304 in 9ms :: {"patients":[{"id":129,"firstName":"ROBE…
|
||||
[2026-01-25T16:57:30.632Z] OPTIONS /api/appointments/day 204 in 0ms
|
||||
[2026-01-25T16:57:30.633Z] OPTIONS /api/staffs/ 204 in 0ms
|
||||
[2026-01-25T16:57:30.649Z] GET /api/appointments/day 200 in 11ms :: {"appointments":[],"patients":[]}
|
||||
[2026-01-25T16:57:30.651Z] GET /api/staffs/ 201 in 6ms :: [{"id":4,"userId":2,"name":"Dr.S","email":null,"…
|
||||
[2026-01-25T16:57:32.881Z] OPTIONS /api/patients/recent 204 in 0ms
|
||||
[2026-01-25T16:57:32.882Z] OPTIONS /api/claims/recent 204 in 0ms
|
||||
[2026-01-25T16:57:32.898Z] GET /api/patients/recent 304 in 12ms :: {"patients":[{"id":129,"firstName":"ROB…
|
||||
[2026-01-25T16:57:32.922Z] GET /api/claims/recent 200 in 32ms :: {"claims":[{"id":160,"patientId":129,"app…
|
||||
[2026-01-25T16:57:39.896Z] OPTIONS /api/patients/128 204 in 0ms
|
||||
[2026-01-25T16:57:39.896Z] OPTIONS /api/staffs/ 204 in 0ms
|
||||
[2026-01-25T16:57:39.897Z] OPTIONS /api/npiProviders/ 204 in 0ms
|
||||
[2026-01-25T16:57:39.904Z] GET /api/patients/128 200 in 4ms :: {"id":128,"firstName":"BORIN","lastName":"K…
|
||||
[2026-01-25T16:57:39.907Z] GET /api/staffs/ 201 in 5ms :: [{"id":4,"userId":2,"name":"Dr.S","email":null,"…
|
||||
[2026-01-25T16:57:39.908Z] GET /api/npiProviders/ 304 in 4ms :: [{"id":2,"userId":2,"npiNumber":"157873261…
|
||||
51
apps/Backend/package.json
Executable file
51
apps/Backend/package.json
Executable file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.9.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"form-data": "^4.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/form-data": "^2.2.1",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/ws": "^8.5.13",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
}
|
||||
}
|
||||
85
apps/Backend/src/app.ts
Executable file
85
apps/Backend/src/app.ts
Executable file
@@ -0,0 +1,85 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import routes from "./routes";
|
||||
import { errorHandler } from "./middlewares/error.middleware";
|
||||
import { apiLogger } from "./middlewares/logger.middleware";
|
||||
import authRoutes from "./routes/auth";
|
||||
import { authenticateJWT } from "./middlewares/auth.middleware";
|
||||
import dotenv from "dotenv";
|
||||
import { startBackupCron } from "./cron/backupCheck";
|
||||
import path from "path";
|
||||
|
||||
dotenv.config();
|
||||
const NODE_ENV = (
|
||||
process.env.NODE_ENV ||
|
||||
process.env.ENV ||
|
||||
"development"
|
||||
).toLowerCase();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true })); // For form data
|
||||
app.use(apiLogger);
|
||||
|
||||
// --- CORS handling (flexible for dev and strict for prod) ---
|
||||
/**
|
||||
* FRONTEND_URLS env value: comma-separated allowed origins
|
||||
* Example: FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
*/
|
||||
const rawFrontendUrls =
|
||||
process.env.FRONTEND_URLS || process.env.FRONTEND_URL || "";
|
||||
const FRONTEND_URLS = rawFrontendUrls
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// helper to see if origin is allowed
|
||||
function isOriginAllowed(origin?: string | null) {
|
||||
if (!origin) return true; // allow non-browser clients (curl/postman)
|
||||
|
||||
if (NODE_ENV !== "production") {
|
||||
// Dev mode: allow localhost origins automatically
|
||||
if (
|
||||
origin.startsWith("http://localhost") ||
|
||||
origin.startsWith("http://127.0.0.1") ||
|
||||
origin.startsWith("http://192.168.0.240")
|
||||
)
|
||||
return true;
|
||||
// allow explicit FRONTEND_URLS if provided
|
||||
if (FRONTEND_URLS.includes(origin)) return true;
|
||||
// optionally allow the server's LAN IP if FRONTEND_LAN_IP is provided
|
||||
const lanIp = process.env.FRONTEND_LAN_IP;
|
||||
if (lanIp && origin.startsWith(`http://${lanIp}`)) return true;
|
||||
// fallback: deny if not matched
|
||||
return false;
|
||||
}
|
||||
|
||||
// production: strict whitelist — must match configured FRONTEND_URLS exactly
|
||||
return FRONTEND_URLS.includes(origin);
|
||||
}
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, cb) => {
|
||||
if (isOriginAllowed(origin)) return cb(null, true);
|
||||
cb(new Error(`CORS: Origin ${origin} not allowed`));
|
||||
},
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Serve static files from uploads directory
|
||||
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
|
||||
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api", authenticateJWT, routes);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
//startig cron job
|
||||
startBackupCron();
|
||||
|
||||
export default app;
|
||||
100
apps/Backend/src/cron/backupCheck.ts
Executable file
100
apps/Backend/src/cron/backupCheck.ts
Executable file
@@ -0,0 +1,100 @@
|
||||
import cron from "node-cron";
|
||||
import fs from "fs";
|
||||
import { storage } from "../storage";
|
||||
import { NotificationTypes } from "@repo/db/types";
|
||||
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||
|
||||
/**
|
||||
* Daily cron job to check if users haven't backed up in 7 days
|
||||
* Creates a backup notification if overdue
|
||||
*/
|
||||
export const startBackupCron = () => {
|
||||
cron.schedule("0 22 * * *", async () => {
|
||||
// Every calendar days, at 10 PM
|
||||
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||
|
||||
console.log("🔄 Running backup check...");
|
||||
|
||||
const userBatchSize = 100;
|
||||
let userOffset = 0;
|
||||
|
||||
while (true) {
|
||||
// Fetch a batch of users
|
||||
const users = await storage.getUsers(userBatchSize, userOffset);
|
||||
if (!users || users.length === 0) break;
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
if (user.id == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(user.id);
|
||||
const lastBackup = await storage.getLastBackup(user.id);
|
||||
|
||||
// ==============================
|
||||
// CASE 1: Destination exists → auto backup
|
||||
// ==============================
|
||||
if (destination) {
|
||||
if (!fs.existsSync(destination.path)) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
"❌ Automatic backup failed: external drive not connected."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const filename = `dental_backup_${Date.now()}.zip`;
|
||||
|
||||
await backupDatabaseToPath({
|
||||
destinationPath: destination.path,
|
||||
filename,
|
||||
});
|
||||
|
||||
await storage.createBackup(user.id);
|
||||
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
||||
|
||||
console.log(`✅ Auto backup successful for user ${user.id}`);
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.error(`Auto backup failed for user ${user.id}`, err);
|
||||
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP",
|
||||
"❌ Automatic backup failed. Please check your backup destination."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// CASE 2: No destination → fallback to reminder
|
||||
// ==============================
|
||||
|
||||
const daysSince = lastBackup?.createdAt
|
||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
: Infinity;
|
||||
|
||||
if (daysSince >= 7) {
|
||||
await storage.createNotification(
|
||||
user.id,
|
||||
"BACKUP" as NotificationTypes,
|
||||
"⚠️ It has been more than 7 days since your last backup."
|
||||
);
|
||||
console.log(`Notification created for user ${user.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing user ${user.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
userOffset += userBatchSize; // next user batch
|
||||
}
|
||||
|
||||
console.log("✅ Daily backup check completed.");
|
||||
});
|
||||
};
|
||||
53
apps/Backend/src/index.ts
Executable file
53
apps/Backend/src/index.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import app from "./app";
|
||||
import dotenv from "dotenv";
|
||||
import http from "http";
|
||||
import { initSocket } from "./socket";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const NODE_ENV = (
|
||||
process.env.NODE_ENV ||
|
||||
process.env.ENV ||
|
||||
"development"
|
||||
).toLowerCase();
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const PORT = Number(process.env.PORT) || 5000;
|
||||
|
||||
// HTTP server from express app
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize socket.io on this server
|
||||
initSocket(server);
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(
|
||||
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
||||
);
|
||||
});
|
||||
|
||||
// Handle startup errors
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(`❌ Port ${PORT} is already in use`);
|
||||
} else {
|
||||
console.error("❌ Server failed to start:", err);
|
||||
}
|
||||
process.exit(1); // Exit with failure
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`⚡ Received ${signal}, shutting down gracefully...`);
|
||||
|
||||
server.close(() => {
|
||||
console.log("✅ HTTP server closed");
|
||||
|
||||
// TODO: Close DB connections if needed
|
||||
// db.$disconnect().then(() => console.log("✅ Database disconnected"));
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
26
apps/Backend/src/middlewares/auth.middleware.ts
Executable file
26
apps/Backend/src/middlewares/auth.middleware.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret'; // Secret used for signing JWTs
|
||||
|
||||
export function authenticateJWT(req: Request, res: Response, next: NextFunction): void{
|
||||
|
||||
// Check the Authorization header for the Bearer token
|
||||
const token = req.header('Authorization')?.split(' ')[1]; // Extract token from Authorization header
|
||||
|
||||
if (!token) {
|
||||
res.status(401).send("Access denied. No token provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify JWT
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) {
|
||||
return res.status(403).send("Forbidden. Invalid token.");
|
||||
}
|
||||
|
||||
// Attach the decoded user data to the request object
|
||||
req.user = decoded as Express.User;
|
||||
next(); // Proceed to the next middleware or route handler
|
||||
});
|
||||
}
|
||||
6
apps/Backend/src/middlewares/error.middleware.ts
Executable file
6
apps/Backend/src/middlewares/error.middleware.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const errorHandler = (err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(err.status || 500).json({ message: err.message || 'Internal Server Error' });
|
||||
};
|
||||
33
apps/Backend/src/middlewares/logger.middleware.ts
Executable file
33
apps/Backend/src/middlewares/logger.middleware.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
function log(message: string) {
|
||||
console.log(`[${new Date().toISOString()}] ${message}`);
|
||||
}
|
||||
|
||||
export function apiLogger(req: Request, res: Response, next: NextFunction) {
|
||||
const start = Date.now();
|
||||
const path = req.path;
|
||||
let capturedJsonResponse: Record<string, any> | undefined = undefined;
|
||||
|
||||
const originalResJson = res.json;
|
||||
res.json = function (bodyJson, ...args) {
|
||||
capturedJsonResponse = bodyJson;
|
||||
return originalResJson.apply(res, [bodyJson, ...args]);
|
||||
};
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
if (path.startsWith("/api")) {
|
||||
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
||||
if (capturedJsonResponse) {
|
||||
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
||||
}
|
||||
if (logLine.length > 80) {
|
||||
logLine = logLine.slice(0, 79) + "…";
|
||||
}
|
||||
log(logLine);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
165
apps/Backend/src/routes/appointments-procedures.ts
Executable file
165
apps/Backend/src/routes/appointments-procedures.ts
Executable file
@@ -0,0 +1,165 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import {
|
||||
insertAppointmentProcedureSchema,
|
||||
updateAppointmentProcedureSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/appointment-procedures/:appointmentId
|
||||
* Get all procedures for an appointment
|
||||
*/
|
||||
router.get("/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
const rows = await storage.getByAppointmentId(appointmentId);
|
||||
|
||||
return res.json(rows);
|
||||
} catch (err: any) {
|
||||
console.error("GET appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/prefill-from-appointment/:appointmentId",
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
|
||||
if (!appointmentId || isNaN(appointmentId)) {
|
||||
return res.status(400).json({ error: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
const data = await storage.getPrefillDataByAppointmentId(appointmentId);
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).json({ error: "Appointment not found" });
|
||||
}
|
||||
|
||||
return res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error("prefill-from-appointment error", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: err.message ?? "Failed to prefill claim data" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures
|
||||
* Add single manual procedure
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = insertAppointmentProcedureSchema.parse(req.body);
|
||||
|
||||
const created = await storage.createProcedure(parsed);
|
||||
|
||||
return res.json(created);
|
||||
} catch (err: any) {
|
||||
console.error("POST appointment procedure error", err);
|
||||
if (err.name === "ZodError") {
|
||||
return res.status(400).json({ message: err.errors });
|
||||
}
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/appointment-procedures/bulk
|
||||
* Add multiple procedures (combos)
|
||||
*/
|
||||
router.post("/bulk", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = req.body;
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return res.status(400).json({ message: "Invalid payload" });
|
||||
}
|
||||
|
||||
const count = await storage.createProceduresBulk(rows);
|
||||
|
||||
return res.json({ success: true, count });
|
||||
} catch (err: any) {
|
||||
console.error("POST bulk appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/appointment-procedures/:id
|
||||
* Update a procedure
|
||||
*/
|
||||
router.put("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: "Invalid id" });
|
||||
}
|
||||
|
||||
const parsed = updateAppointmentProcedureSchema.parse(req.body);
|
||||
|
||||
const updated = await storage.updateProcedure(id, parsed);
|
||||
|
||||
return res.json(updated);
|
||||
} catch (err: any) {
|
||||
console.error("PUT appointment procedure error", err);
|
||||
|
||||
if (err.name === "ZodError") {
|
||||
return res.status(400).json({ message: err.errors });
|
||||
}
|
||||
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/appointment-procedures/:id
|
||||
* Delete single procedure
|
||||
*/
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: "Invalid id" });
|
||||
}
|
||||
|
||||
await storage.deleteProcedure(id);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("DELETE appointment procedure error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/appointment-procedures/clear/:appointmentId
|
||||
* Clear all procedures for appointment
|
||||
*/
|
||||
router.delete("/clear/:appointmentId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const appointmentId = Number(req.params.appointmentId);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ message: "Invalid appointmentId" });
|
||||
}
|
||||
|
||||
await storage.clearByAppointmentId(appointmentId);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error("CLEAR appointment procedures error", err);
|
||||
return res.status(500).json({ message: err.message ?? "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
439
apps/Backend/src/routes/appointments.ts
Executable file
439
apps/Backend/src/routes/appointments.ts
Executable file
@@ -0,0 +1,439 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
insertAppointmentSchema,
|
||||
updateAppointmentSchema,
|
||||
} from "@repo/db/types";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all appointments
|
||||
router.get("/all", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const appointments = await storage.getAllAppointments();
|
||||
|
||||
res.json(appointments);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve all appointments" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/appointments/day?date=YYYY-MM-DD
|
||||
* Response: { appointments: Appointment[], patients: Patient[] }
|
||||
*/
|
||||
router.get("/day", async (req: Request, res: Response): Promise<any> => {
|
||||
function isValidYMD(s: string) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDate = req.query.date as string | undefined;
|
||||
if (!rawDate || !isValidYMD(rawDate)) {
|
||||
return res.status(400).json({ message: "Date query param is required." });
|
||||
}
|
||||
if (!req.user) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
// Build literal UTC day bounds from the YYYY-MM-DD query string
|
||||
const start = new Date(`${rawDate}T00:00:00.000Z`);
|
||||
const end = new Date(`${rawDate}T23:59:59.999Z`);
|
||||
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||
return res.status(400).json({ message: "Invalid date format" });
|
||||
}
|
||||
|
||||
// Call the storage method that takes a start/end range (no change to storage needed)
|
||||
const appointments = await storage.getAppointmentsOnRange(start, end);
|
||||
|
||||
// dedupe patient ids referenced by those appointments
|
||||
const patientIds = Array.from(
|
||||
new Set(appointments.map((a) => a.patientId).filter(Boolean))
|
||||
);
|
||||
|
||||
const patients = patientIds.length
|
||||
? await storage.getPatientsByIds(patientIds)
|
||||
: [];
|
||||
|
||||
return res.json({ appointments, patients });
|
||||
} catch (err) {
|
||||
console.error("Error in /api/appointments/day:", err);
|
||||
res.status(500).json({ message: "Failed to load appointments for date" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent appointments (paginated)
|
||||
router.get("/recent", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.max(1, parseInt(req.query.limit as string) || 10);
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const all = await storage.getRecentAppointments(limit, offset);
|
||||
res.json({ data: all, limit, offset });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: "Failed to get recent appointments" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single appointment by ID
|
||||
router.get(
|
||||
"/:id",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const appointmentIdParam = req.params.id;
|
||||
|
||||
// Ensure that patientIdParam exists and is a valid number
|
||||
if (!appointmentIdParam) {
|
||||
return res.status(400).json({ message: "Appointment ID is required" });
|
||||
}
|
||||
|
||||
const appointmentId = parseInt(appointmentIdParam);
|
||||
|
||||
const appointment = await storage.getAppointment(appointmentId);
|
||||
|
||||
if (!appointment) {
|
||||
return res.status(404).json({ message: "Appointment not found" });
|
||||
}
|
||||
|
||||
res.json(appointment);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve appointment" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get all appointments for a specific patient
|
||||
router.get(
|
||||
"/:patientId/appointments",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const rawPatientId = req.params.patientId;
|
||||
if (!rawPatientId) {
|
||||
return res.status(400).json({ message: "Patient ID is required" });
|
||||
}
|
||||
|
||||
const patientId = parseInt(rawPatientId);
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
}
|
||||
|
||||
const patient = await storage.getPatient(patientId);
|
||||
if (!patient)
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
|
||||
const appointments = await storage.getAppointmentsByPatientId(patientId);
|
||||
res.json(appointments);
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: "Failed to get patient appointments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/appointments/:id/patient
|
||||
*/
|
||||
router.get(
|
||||
"/:id/patient",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const rawId = req.params.id;
|
||||
if (!rawId) {
|
||||
return res.status(400).json({ message: "Appointment ID is required" });
|
||||
}
|
||||
|
||||
const apptId = parseInt(rawId, 10);
|
||||
if (Number.isNaN(apptId) || apptId <= 0) {
|
||||
return res.status(400).json({ message: "Invalid appointment ID" });
|
||||
}
|
||||
|
||||
const patient = await storage.getPatientFromAppointmentId(apptId);
|
||||
|
||||
if (!patient) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: "Patient not found for the given appointment" });
|
||||
}
|
||||
|
||||
return res.json(patient);
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to retrieve patient for appointment" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new appointment
|
||||
router.post(
|
||||
"/upsert",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// Validate request body
|
||||
const appointmentData = insertAppointmentSchema.parse({
|
||||
...req.body,
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
const originalStartTime = appointmentData.startTime;
|
||||
const MAX_END_TIME = "18:30";
|
||||
|
||||
// 1. Verify patient exists and belongs to user
|
||||
const patient = await storage.getPatient(appointmentData.patientId);
|
||||
if (!patient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
|
||||
// 2. 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
|
||||
const staffConflict = await storage.getStaffAppointmentByDateTime(
|
||||
appointmentData.staffId,
|
||||
appointmentData.date,
|
||||
currentStartTime,
|
||||
sameDayAppointment?.id // Ignore self if updating
|
||||
);
|
||||
|
||||
if (!staffConflict) {
|
||||
const endMinute = minute + APPT_DURATION_MINUTES;
|
||||
let endHour = hour + Math.floor(endMinute / 60);
|
||||
let realEndMinute = endMinute % 60;
|
||||
|
||||
const currentEndTime = `${pad(endHour)}:${pad(realEndMinute)}`;
|
||||
|
||||
const payload = {
|
||||
...appointmentData,
|
||||
startTime: currentStartTime,
|
||||
endTime: currentEndTime,
|
||||
};
|
||||
|
||||
let responseData;
|
||||
|
||||
if (sameDayAppointment?.id !== undefined) {
|
||||
const updated = await storage.updateAppointment(
|
||||
sameDayAppointment.id,
|
||||
payload
|
||||
);
|
||||
responseData = {
|
||||
...updated,
|
||||
originalRequestedTime: originalStartTime,
|
||||
finalScheduledTime: currentStartTime,
|
||||
message:
|
||||
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 = {
|
||||
...created,
|
||||
originalRequestedTime: originalStartTime,
|
||||
finalScheduledTime: currentStartTime,
|
||||
message:
|
||||
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
|
||||
minute += STEP_MINUTES;
|
||||
if (minute >= 60) {
|
||||
hour += Math.floor(minute / 60);
|
||||
minute = minute % 60;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(409).json({
|
||||
message:
|
||||
"No available slots remaining until 6:30 PM for this Staff. Please choose another day.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in upsert appointment:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(
|
||||
"Validation error details:",
|
||||
JSON.stringify(error.format(), null, 2)
|
||||
);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
message: "Failed to upsert appointment",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update an existing appointment
|
||||
router.put(
|
||||
"/:id",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const appointmentData = updateAppointmentSchema.parse({
|
||||
...req.body,
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
const appointmentIdParam = req.params.id;
|
||||
if (!appointmentIdParam) {
|
||||
return res.status(400).json({ message: "Appointment ID is required" });
|
||||
}
|
||||
const appointmentId = parseInt(appointmentIdParam);
|
||||
|
||||
// 1. Verify patient exists and belongs to user
|
||||
const patient = await storage.getPatient(appointmentData.patientId);
|
||||
if (!patient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
|
||||
// 2. Check if appointment exists and belongs to user
|
||||
const existingAppointment = await storage.getAppointment(appointmentId);
|
||||
if (!existingAppointment) {
|
||||
console.log("Appointment not found:", appointmentId);
|
||||
return res.status(404).json({ message: "Appointment not found" });
|
||||
}
|
||||
|
||||
// 4. Reject patientId change (not allowed)
|
||||
if (
|
||||
appointmentData.patientId &&
|
||||
appointmentData.patientId !== existingAppointment.patientId
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Changing patientId is not allowed" });
|
||||
}
|
||||
|
||||
// 5. Check for conflicting appointments (same patient OR staff at same time)
|
||||
|
||||
const date = appointmentData.date ?? existingAppointment.date;
|
||||
const startTime =
|
||||
appointmentData.startTime ?? existingAppointment.startTime;
|
||||
const staffId = appointmentData.staffId ?? existingAppointment.staffId;
|
||||
|
||||
const patientConflict = await storage.getPatientConflictAppointment(
|
||||
existingAppointment.patientId,
|
||||
date,
|
||||
startTime,
|
||||
appointmentId
|
||||
);
|
||||
|
||||
if (patientConflict) {
|
||||
return res.status(409).json({
|
||||
message: "This patient already has an appointment at this time.",
|
||||
});
|
||||
}
|
||||
|
||||
const staffConflict = await storage.getStaffConflictAppointment(
|
||||
staffId,
|
||||
date,
|
||||
startTime,
|
||||
appointmentId
|
||||
);
|
||||
|
||||
if (staffConflict) {
|
||||
return res.status(409).json({
|
||||
message: "This time slot is already booked for the selected staff.",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. if date gets updated, then also update the aptmnt status to unknown.
|
||||
// Normalize to YYYY-MM-DD to avoid timezone problems (model uses @db.Date)
|
||||
const oldYMD = new Date(existingAppointment.date)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const newYMD = new Date(date).toISOString().slice(0, 10);
|
||||
const isDateChanged = oldYMD !== newYMD;
|
||||
|
||||
const updatePayload = {
|
||||
...appointmentData,
|
||||
...(isDateChanged ? { eligibilityStatus: "UNKNOWN" as const } : {}),
|
||||
};
|
||||
|
||||
// Update appointment
|
||||
const updatedAppointment = await storage.updateAppointment(
|
||||
appointmentId,
|
||||
updatePayload
|
||||
);
|
||||
return res.json(updatedAppointment);
|
||||
} catch (error) {
|
||||
console.error("Error updating appointment:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(
|
||||
"Validation error details:",
|
||||
JSON.stringify(error.format(), null, 2)
|
||||
);
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
message: "Failed to update appointment",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete an appointment
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const appointmentIdParam = req.params.id;
|
||||
if (!appointmentIdParam) {
|
||||
return res.status(400).json({ message: "Appointment ID is required" });
|
||||
}
|
||||
const appointmentId = parseInt(appointmentIdParam);
|
||||
|
||||
// Check if appointment exists and belongs to user
|
||||
const existingAppointment = await storage.getAppointment(appointmentId);
|
||||
if (!existingAppointment) {
|
||||
return res.status(404).json({ message: "Appointment not found" });
|
||||
}
|
||||
|
||||
if (existingAppointment.userId !== req.user!.id) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Appointment belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete appointment
|
||||
await storage.deleteAppointment(appointmentId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to delete appointment" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
99
apps/Backend/src/routes/auth.ts
Executable file
99
apps/Backend/src/routes/auth.ts
Executable file
@@ -0,0 +1,99 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import bcrypt from "bcrypt";
|
||||
import { storage } from "../storage";
|
||||
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import { z } from "zod";
|
||||
|
||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-jwt-secret";
|
||||
const JWT_EXPIRATION = "24h"; // JWT expiration time (1 day)
|
||||
|
||||
// Function to hash password using bcrypt
|
||||
async function hashPassword(password: string) {
|
||||
const saltRounds = 10; // Salt rounds for bcrypt
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// Function to compare passwords using bcrypt
|
||||
async function comparePasswords(supplied: string, stored: string) {
|
||||
const isMatch = await bcrypt.compare(supplied, stored);
|
||||
return isMatch;
|
||||
}
|
||||
|
||||
// Function to generate JWT
|
||||
function generateToken(user: SelectUser) {
|
||||
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRATION,
|
||||
});
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// User registration route
|
||||
router.post(
|
||||
"/register",
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||
try {
|
||||
const existingUser = await storage.getUserByUsername(req.body.username);
|
||||
if (existingUser) {
|
||||
return res.status(400).send("Username already exists");
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(req.body.password);
|
||||
const user = await storage.createUser({
|
||||
...req.body,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
// Generate a JWT token for the user after successful registration
|
||||
const token = generateToken(user);
|
||||
|
||||
const { password, ...safeUser } = user;
|
||||
return res.status(201).json({ user: safeUser, token });
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// User login route
|
||||
router.post(
|
||||
"/login",
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<any> => {
|
||||
try {
|
||||
const user = await storage.getUserByUsername(req.body.username);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const isPasswordMatch = await comparePasswords(
|
||||
req.body.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordMatch) {
|
||||
return res.status(401).json({ error: "Invalid password or password" });
|
||||
}
|
||||
|
||||
// Generate a JWT token for the user after successful login
|
||||
const token = generateToken(user);
|
||||
const { password, ...safeUser } = user;
|
||||
return res.status(200).json({ user: safeUser, token });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Logout route (client-side action to remove the token)
|
||||
router.post("/logout", (req: Request, res: Response) => {
|
||||
// For JWT-based auth, logout is handled on the client (by removing token)
|
||||
res.status(200).send("Logged out successfully");
|
||||
});
|
||||
|
||||
export default router;
|
||||
578
apps/Backend/src/routes/claims.ts
Executable file
578
apps/Backend/src/routes/claims.ts
Executable file
@@ -0,0 +1,578 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import multer from "multer";
|
||||
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { Prisma } from "@repo/db/generated/prisma";
|
||||
import { Decimal } from "decimal.js";
|
||||
import {
|
||||
ExtendedClaimSchema,
|
||||
InputServiceLine,
|
||||
updateClaimSchema,
|
||||
} from "@repo/db/types";
|
||||
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Routes
|
||||
const multerStorage = multer.memoryStorage(); // NO DISK
|
||||
const upload = multer({
|
||||
storage: multerStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Unsupported file type"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/mh-provider-login",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { memberId, dateOfBirth, submissionDate, firstName, lastName, procedureCode, toothNumber, toothSurface, insuranceSiteKey } = req.body;
|
||||
if (!memberId || !dateOfBirth || !submissionDate || !firstName || !lastName || !procedureCode || !insuranceSiteKey) {
|
||||
return res.status(400).json({ error: "Missing required fields: memberId, dateOfBirth, submissionDate, firstName, lastName, procedureCode, insuranceSiteKey" });
|
||||
}
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
data: {
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
submissionDate,
|
||||
firstName,
|
||||
lastName,
|
||||
procedureCode,
|
||||
toothNumber,
|
||||
toothSurface,
|
||||
insuranceSiteKey,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
},
|
||||
};
|
||||
|
||||
const seleniumRes = await axios.post(
|
||||
"http://localhost:5002/claims-login",
|
||||
enrichedData
|
||||
);
|
||||
|
||||
const result = seleniumRes.data;
|
||||
if (result?.status !== "success") {
|
||||
return res.status(502).json({ error: result?.message || "Selenium service error" });
|
||||
}
|
||||
|
||||
return res.json({ status: "success", message: "Claims automation completed. Browser remains open." });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err?.message || "Failed to contact selenium service",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/selenium-claim",
|
||||
upload.fields([
|
||||
{ name: "pdfs", maxCount: 10 },
|
||||
{ name: "images", maxCount: 10 },
|
||||
]),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.files || !req.body.data) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing files or claim data for selenium" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
try {
|
||||
const claimData = JSON.parse(req.body.data);
|
||||
const pdfs =
|
||||
(req.files as Record<string, Express.Multer.File[]>).pdfs ?? [];
|
||||
const images =
|
||||
(req.files as Record<string, Express.Multer.File[]>).images ?? [];
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
claimData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...claimData,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
|
||||
const result = await forwardToSeleniumClaimAgent(enrichedData, [
|
||||
...pdfs,
|
||||
...images,
|
||||
]);
|
||||
|
||||
// Store claimNumber if returned from Selenium
|
||||
if (result?.claimNumber && claimData.claimId) {
|
||||
try {
|
||||
await storage.updateClaim(claimData.claimId, {
|
||||
claimNumber: result.claimNumber,
|
||||
});
|
||||
console.log(`Updated claim ${claimData.claimId} with claimNumber: ${result.claimNumber}`);
|
||||
} catch (updateErr) {
|
||||
console.error("Failed to update claim with claimNumber:", updateErr);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
claimId: claimData.claimId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to forward to selenium agent",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/selenium/fetchpdf",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
function sendError(res: Response, message: string, status = 400) {
|
||||
console.error("Error:", message);
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!req.user || !req.user.id) {
|
||||
return sendError(res, "Unauthorized: user info missing", 401);
|
||||
}
|
||||
|
||||
const { patientId, pdf_url, groupTitleKey } = req.body;
|
||||
|
||||
if (!pdf_url) {
|
||||
return sendError(res, "Missing pdf_url");
|
||||
}
|
||||
|
||||
if (!patientId) {
|
||||
return sendError(res, "Missing Patient Id");
|
||||
}
|
||||
|
||||
const parsedPatientId = parseInt(patientId);
|
||||
|
||||
console.log("Fetching PDF from URL:", pdf_url);
|
||||
const filename = path.basename(new URL(pdf_url).pathname);
|
||||
console.log("Extracted filename:", filename);
|
||||
|
||||
// Always fetch from localhost regardless of what hostname is in the pdf_url,
|
||||
// since both backend and selenium service run on the same machine.
|
||||
const seleniumPort = process.env.SELENIUM_PORT || "5002";
|
||||
const localPdfUrl = `http://localhost:${seleniumPort}/downloads/${filename}`;
|
||||
console.log("Fetching PDF from local URL:", localPdfUrl);
|
||||
|
||||
const pdfResponse = await axios.get(localPdfUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 15000,
|
||||
});
|
||||
console.log("PDF fetched successfully, size:", pdfResponse.data.length);
|
||||
|
||||
// Allowed keys as a literal tuple to derive a union type
|
||||
const allowedKeys = [
|
||||
"INSURANCE_CLAIM",
|
||||
"INSURANCE_CLAIM_PREAUTH",
|
||||
] as const;
|
||||
type GroupKey = (typeof allowedKeys)[number];
|
||||
const isGroupKey = (v: any): v is GroupKey =>
|
||||
(allowedKeys as readonly string[]).includes(v);
|
||||
|
||||
if (!isGroupKey(groupTitleKey)) {
|
||||
return sendError(
|
||||
res,
|
||||
`Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const GROUP_TITLES: Record<GroupKey, string> = {
|
||||
INSURANCE_CLAIM: "Claims",
|
||||
INSURANCE_CLAIM_PREAUTH: "Claims Preauth",
|
||||
};
|
||||
const groupTitle = GROUP_TITLES[groupTitleKey];
|
||||
|
||||
// ✅ Find or create PDF group for this claim
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
parsedPatientId,
|
||||
groupTitleKey
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
parsedPatientId,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Save PDF file into that group
|
||||
const created = await storage.createPdfFile(group.id!, filename, pdfResponse.data);
|
||||
|
||||
// Extract the PDF file ID for opening the viewer
|
||||
let pdfFileId: number | null = null;
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
pdfFileId = Number(created.id);
|
||||
} else if (typeof created === "number") {
|
||||
pdfFileId = created;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
pdfPath: `/temp/${filename}`,
|
||||
pdf_url,
|
||||
fileName: filename,
|
||||
pdfFileId,
|
||||
// pdfFilename: filename,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Error in /selenium/fetchpdf:", err);
|
||||
console.error("Error details:", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
response: err.response?.status,
|
||||
responseData: err.response?.data,
|
||||
});
|
||||
const errorMsg = err.response?.data || err.message || "Failed to Fetch and Download the pdf";
|
||||
return sendError(res, `Failed to Fetch and Download the pdf: ${errorMsg}`, 500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/selenium-claim-pre-auth",
|
||||
upload.fields([
|
||||
{ name: "pdfs", maxCount: 10 },
|
||||
{ name: "images", maxCount: 10 },
|
||||
]),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.files || !req.body.data) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing files or claim data for selenium" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
try {
|
||||
const claimData = JSON.parse(req.body.data);
|
||||
const pdfs =
|
||||
(req.files as Record<string, Express.Multer.File[]>).pdfs ?? [];
|
||||
const images =
|
||||
(req.files as Record<string, Express.Multer.File[]>).images ?? [];
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
claimData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider. Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...claimData,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
|
||||
const result = await forwardToSeleniumClaimPreAuthAgent(enrichedData, [
|
||||
...pdfs,
|
||||
...images,
|
||||
]);
|
||||
|
||||
res.json({
|
||||
...result,
|
||||
claimId: claimData.claimId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to forward to selenium agent",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/claims/recent
|
||||
router.get("/recent", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const [claims, totalCount] = await Promise.all([
|
||||
storage.getRecentClaims(limit, offset),
|
||||
storage.getTotalClaimCount(),
|
||||
]);
|
||||
|
||||
res.json({ claims, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve recent claims:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve recent claims" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/claims/patient/:patientId
|
||||
router.get(
|
||||
"/patient/:patientId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = Array.isArray(req.params.patientId) ? req.params.patientId[0] : req.params.patientId;
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Missing patientId" });
|
||||
}
|
||||
const patientId = parseInt(patientIdParam);
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patientId" });
|
||||
}
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
}
|
||||
|
||||
const [claims, totalCount] = await Promise.all([
|
||||
storage.getRecentClaimsByPatientId(patientId, limit, offset),
|
||||
storage.getTotalClaimCountByPatient(patientId),
|
||||
]);
|
||||
|
||||
res.json({ claims, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve claims for patient:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve patient claims" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get all claims count.
|
||||
router.get("/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const claims = await storage.getTotalClaimCount();
|
||||
res.json(claims);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve claims count" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single claim by ID
|
||||
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing claim ID" });
|
||||
}
|
||||
const claimId = parseInt(idParam, 10);
|
||||
if (isNaN(claimId)) {
|
||||
return res.status(400).json({ error: "Invalid claim ID" });
|
||||
}
|
||||
|
||||
const claim = await storage.getClaim(claimId);
|
||||
if (!claim) {
|
||||
return res.status(404).json({ message: "Claim not found" });
|
||||
}
|
||||
|
||||
res.json(claim);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve claim" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new claim
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// --- TRANSFORM claimFiles (if provided) into Prisma nested-create shape
|
||||
if (Array.isArray(req.body.claimFiles)) {
|
||||
// each item expected: { filename: string, mimeType: string }
|
||||
req.body.claimFiles = {
|
||||
create: req.body.claimFiles.map((f: any) => ({
|
||||
filename: String(f.filename),
|
||||
mimeType: String(f.mimeType || f.mime || ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// --- TRANSFORM serviceLines
|
||||
if (
|
||||
!Array.isArray(req.body.serviceLines) ||
|
||||
req.body.serviceLines.length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
message: "At least one service line is required to create a claim",
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(req.body.serviceLines)) {
|
||||
req.body.serviceLines = req.body.serviceLines.map(
|
||||
(line: InputServiceLine) => ({
|
||||
...line,
|
||||
totalBilled: Number(line.totalBilled),
|
||||
totalAdjusted: 0,
|
||||
totalPaid: 0,
|
||||
totalDue: Number(line.totalBilled),
|
||||
})
|
||||
);
|
||||
req.body.serviceLines = { create: req.body.serviceLines };
|
||||
}
|
||||
|
||||
const parsedClaim = ExtendedClaimSchema.parse({
|
||||
...req.body,
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
// Step 1: Calculate total billed from service lines
|
||||
const serviceLinesCreateInput = (
|
||||
parsedClaim.serviceLines as Prisma.ServiceLineCreateNestedManyWithoutClaimInput
|
||||
)?.create;
|
||||
const lines = Array.isArray(serviceLinesCreateInput)
|
||||
? (serviceLinesCreateInput as unknown as {
|
||||
totalBilled: number | string;
|
||||
}[])
|
||||
: [];
|
||||
const totalBilled = lines.reduce(
|
||||
(sum, line) => sum + Number(line.totalBilled ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Step 2: Create claim (with service lines)
|
||||
const claim = await storage.createClaim(parsedClaim);
|
||||
|
||||
// Step 3: Create empty payment
|
||||
await storage.createPayment({
|
||||
claimId: claim.id,
|
||||
patientId: claim.patientId,
|
||||
userId: req.user!.id,
|
||||
totalBilled: new Decimal(totalBilled),
|
||||
totalPaid: new Decimal(0),
|
||||
totalDue: new Decimal(totalBilled),
|
||||
status: "PENDING",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
res.status(201).json(claim);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
console.error("❌ Failed to create claim:", error); // logs full error to server
|
||||
|
||||
// Send more detailed info to the client (for dev only)
|
||||
return res.status(500).json({
|
||||
message: "Failed to create claim",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update a claim
|
||||
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing claim ID" });
|
||||
}
|
||||
|
||||
const claimId = parseInt(idParam, 10);
|
||||
if (isNaN(claimId)) {
|
||||
return res.status(400).json({ error: "Invalid claim ID" });
|
||||
}
|
||||
|
||||
const existingClaim = await storage.getClaim(claimId);
|
||||
if (!existingClaim) {
|
||||
return res.status(404).json({ message: "Claim not found" });
|
||||
}
|
||||
|
||||
const claimData = updateClaimSchema.parse(req.body);
|
||||
const updatedClaim = await storage.updateClaim(claimId, claimData);
|
||||
res.json(updatedClaim);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update claim" });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a claim
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing claim ID" });
|
||||
}
|
||||
|
||||
const claimId = parseInt(idParam, 10);
|
||||
if (isNaN(claimId)) {
|
||||
return res.status(400).json({ error: "Invalid claim ID" });
|
||||
}
|
||||
|
||||
const existingClaim = await storage.getClaim(claimId);
|
||||
if (!existingClaim) {
|
||||
return res.status(404).json({ message: "Claim not found" });
|
||||
}
|
||||
|
||||
if (existingClaim.userId !== req.user!.id) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Claim belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
|
||||
await storage.deleteClaim(claimId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to delete claim" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
556
apps/Backend/src/routes/cloud-storage.ts
Executable file
556
apps/Backend/src/routes/cloud-storage.ts
Executable file
@@ -0,0 +1,556 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import storage from "../storage";
|
||||
import { serializeFile } from "../utils/prismaFileUtils";
|
||||
import { CloudFolder } from "@repo/db/types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
function parsePositiveInt(v: unknown, fallback: number) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function sendError(
|
||||
res: Response,
|
||||
status: number,
|
||||
message: string,
|
||||
details?: any
|
||||
) {
|
||||
return res.status(status).json({ error: true, message, details });
|
||||
}
|
||||
|
||||
/* ---------- Paginated child FOLDERS for a parent ----------
|
||||
GET /items/folders?parentId=&limit=&offset=
|
||||
parentId may be "null" or numeric or absent (means root)
|
||||
*/
|
||||
router.get(
|
||||
"/items/folders",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const rawParent = req.query.parentId;
|
||||
const parentId =
|
||||
rawParent === undefined
|
||||
? null
|
||||
: rawParent === "null"
|
||||
? null
|
||||
: Number(rawParent);
|
||||
|
||||
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
|
||||
return sendError(res, 400, "Invalid parentId");
|
||||
}
|
||||
|
||||
const limit = parsePositiveInt(req.query.limit, 10); // default 10 folders/page
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
|
||||
try {
|
||||
// Prefer a storage method that lists folders by parent, otherwise filter
|
||||
let data: CloudFolder[] = [];
|
||||
if (typeof (storage as any).listFoldersByParent === "function") {
|
||||
data = await (storage as any).listFoldersByParent(
|
||||
parentId,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
const total =
|
||||
(await (storage as any).countFoldersByParent?.(parentId)) ??
|
||||
data.length;
|
||||
return res.json({ error: false, data, total, limit, offset });
|
||||
}
|
||||
|
||||
// Fallback: use recent and filter (less efficient). Recommend implementing listFoldersByParent in storage.
|
||||
const recent = await storage.listRecentFolders(1000, 0);
|
||||
const folders = (recent || []).filter(
|
||||
(f: any) => (f as any).parentId === parentId
|
||||
);
|
||||
const paged = folders.slice(offset, offset + limit);
|
||||
return res.json({
|
||||
error: false,
|
||||
data: paged,
|
||||
totalCount: folders.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to load child folders", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- Paginated files for a folder ----------
|
||||
GET /items/files?parentId=&limit=&offset=
|
||||
parentId may be "null" or numeric or absent (means root)
|
||||
*/
|
||||
router.get(
|
||||
"/items/files",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const rawParent = req.query.parentId;
|
||||
const parentId =
|
||||
rawParent === undefined
|
||||
? null
|
||||
: rawParent === "null"
|
||||
? null
|
||||
: Number(rawParent);
|
||||
|
||||
if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) {
|
||||
return sendError(res, 400, "Invalid parentId");
|
||||
}
|
||||
|
||||
const limit = parsePositiveInt(req.query.limit, 20); // default 20 files/page
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
|
||||
try {
|
||||
const files = await storage.listFilesInFolder(parentId, limit, offset);
|
||||
const totalCount = await storage.countFilesInFolder(parentId);
|
||||
const serialized = files.map(serializeFile);
|
||||
return res.json({ error: false, data: serialized, totalCount });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to load files for folder", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- Recent folders (global) ----------
|
||||
GET /folders/recent?limit=&offset=
|
||||
*/
|
||||
router.get(
|
||||
"/folders/recent",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const limit = parsePositiveInt(req.query.limit, 50);
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
try {
|
||||
// Always request top-level folders (parentId = null)
|
||||
const parentId: number | null = null;
|
||||
const folders = await storage.listRecentFolders(limit, offset, parentId);
|
||||
const totalCount = await storage.countFoldersByParent(parentId);
|
||||
|
||||
return res.json({
|
||||
error: false,
|
||||
data: folders,
|
||||
totalCount,
|
||||
});
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to load recent folders");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---------- Folder CRUD ----------
|
||||
router.get(
|
||||
"/folders/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid folder id");
|
||||
|
||||
try {
|
||||
const folder = await storage.getFolder(id);
|
||||
if (!folder) return sendError(res, 404, "Folder not found");
|
||||
return res.json({ error: false, data: folder });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to load folder");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post("/folders", async (req: Request, res: Response): Promise<any> => {
|
||||
const { userId, name, parentId } = req.body;
|
||||
if (!userId || typeof name !== "string" || !name.trim()) {
|
||||
return sendError(res, 400, "Missing or invalid userId/name");
|
||||
}
|
||||
try {
|
||||
const created = await storage.createFolder(
|
||||
userId,
|
||||
name.trim(),
|
||||
parentId ?? null
|
||||
);
|
||||
return res.status(201).json({ error: false, data: created });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to create folder");
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/folders/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
// coerce possibly-undefined param to string before parsing
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid folder id");
|
||||
|
||||
const updates: any = {};
|
||||
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
|
||||
if (req.body.parentId !== undefined) updates.parentId = req.body.parentId;
|
||||
|
||||
try {
|
||||
const updated = await storage.updateFolder(id, updates);
|
||||
if (!updated)
|
||||
return sendError(res, 404, "Folder not found or update failed");
|
||||
return res.json({ error: false, data: updated });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to update folder");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/folders/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid folder id");
|
||||
try {
|
||||
const ok = await storage.deleteFolder(id);
|
||||
if (!ok) return sendError(res, 404, "Folder not found or delete failed");
|
||||
return res.json({ error: false, data: { id } });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to delete folder");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- Files inside folder (pagination) ----------
|
||||
GET /folders/:id/files?limit=&offset=
|
||||
id = "null" lists files with folderId = null
|
||||
responses serialized
|
||||
*/
|
||||
router.get(
|
||||
"/folders/:id/files",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const rawId = req.params.id;
|
||||
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
|
||||
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
|
||||
return sendError(res, 400, "Invalid folder id");
|
||||
}
|
||||
|
||||
const limit = parsePositiveInt(req.query.limit, 50);
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
|
||||
try {
|
||||
const files = await storage.listFilesInFolder(folderId, limit, offset);
|
||||
const totalCount = await storage.countFilesInFolder(folderId);
|
||||
const serialized = files.map(serializeFile);
|
||||
return res.json({ error: false, data: serialized, totalCount });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to list files for folder");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- File CRUD (init, update metadata, delete) ----------
|
||||
POST /folders/:id/files { userId, name, mimeType?, expectedSize?, totalChunks? }
|
||||
PUT /files/:id { name?, mimeType?, folderId? }
|
||||
DELETE /files/:id
|
||||
*/
|
||||
const MAX_FILE_MB = 20;
|
||||
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
||||
|
||||
router.post(
|
||||
"/folders/:id/files",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const rawId = req.params.id;
|
||||
const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10);
|
||||
if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) {
|
||||
return sendError(res, 400, "Invalid folder id");
|
||||
}
|
||||
|
||||
const { userId, name, mimeType } = req.body;
|
||||
if (!userId || typeof name !== "string" || !name.trim()) {
|
||||
return sendError(res, 400, "Missing or invalid userId/name");
|
||||
}
|
||||
|
||||
// coerce size & chunks
|
||||
let expectedSize: bigint | null = null;
|
||||
if (req.body.expectedSize != null) {
|
||||
try {
|
||||
// coerce to BigInt safely
|
||||
const asNum = Number(req.body.expectedSize);
|
||||
if (!Number.isFinite(asNum) || asNum < 0) {
|
||||
return sendError(res, 400, "Invalid expectedSize");
|
||||
}
|
||||
if (asNum > MAX_FILE_BYTES) {
|
||||
// Payload Too Large
|
||||
return sendError(
|
||||
res,
|
||||
413,
|
||||
`File too large. Max allowed is ${MAX_FILE_MB} MB`
|
||||
);
|
||||
}
|
||||
expectedSize = BigInt(String(req.body.expectedSize));
|
||||
} catch {
|
||||
return sendError(res, 400, "Invalid expectedSize");
|
||||
}
|
||||
}
|
||||
|
||||
let totalChunks: number | null = null;
|
||||
if (req.body.totalChunks != null) {
|
||||
const tc = Number(req.body.totalChunks);
|
||||
if (!Number.isFinite(tc) || tc <= 0)
|
||||
return sendError(res, 400, "Invalid totalChunks");
|
||||
totalChunks = Math.floor(tc);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await storage.initializeFileUpload(
|
||||
userId,
|
||||
name.trim(),
|
||||
mimeType ?? null,
|
||||
expectedSize,
|
||||
totalChunks,
|
||||
folderId
|
||||
);
|
||||
return res
|
||||
.status(201)
|
||||
.json({ error: false, data: serializeFile(created as any) });
|
||||
} catch {
|
||||
return sendError(res, 500, "Failed to create file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- 2. CHUNKS (raw upload) ---------- */
|
||||
router.post(
|
||||
"/files/:id/chunks",
|
||||
// only here: use express.raw so req.body is Buffer
|
||||
express.raw({ type: () => true, limit: "100mb" }),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
const seq = Number.parseInt(
|
||||
String(req.query.seq ?? req.body.seq ?? ""),
|
||||
10
|
||||
);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
if (!Number.isInteger(seq) || seq < 0)
|
||||
return sendError(res, 400, "Invalid seq");
|
||||
|
||||
const body = req.body as Buffer;
|
||||
|
||||
if (!body || !(body instanceof Buffer)) {
|
||||
return sendError(res, 400, "Expected raw binary body (Buffer)");
|
||||
}
|
||||
|
||||
// strict size guard: any single chunk must not exceed MAX_FILE_BYTES
|
||||
if (body.length > MAX_FILE_BYTES) {
|
||||
return sendError(res, 413, `Chunk size exceeds ${MAX_FILE_MB} MB limit`);
|
||||
}
|
||||
|
||||
try {
|
||||
await storage.appendFileChunk(id, seq, body);
|
||||
return res.json({ error: false, data: { fileId: id, seq } });
|
||||
} catch (err: any) {
|
||||
return sendError(res, 500, "Failed to add chunk");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- 3. COMPLETE ---------- */
|
||||
router.post(
|
||||
"/files/:id/complete",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
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) {
|
||||
return sendError(res, 500, err?.message || "Failed to complete file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.put("/files/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
|
||||
const updates: any = {};
|
||||
if (typeof req.body.name === "string") updates.name = req.body.name.trim();
|
||||
if (typeof req.body.mimeType === "string")
|
||||
updates.mimeType = req.body.mimeType;
|
||||
if (req.body.folderId !== undefined) updates.folderId = req.body.folderId;
|
||||
|
||||
try {
|
||||
const updated = await storage.updateFile(id, updates);
|
||||
if (!updated) return sendError(res, 404, "File not found or update failed");
|
||||
return res.json({ error: false, data: serializeFile(updated as any) });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to update file metadata");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete(
|
||||
"/files/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
|
||||
try {
|
||||
const ok = await storage.deleteFile(id);
|
||||
if (!ok) return sendError(res, 404, "File not found or delete failed");
|
||||
return res.json({ error: false, data: { id } });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to delete file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* GET /files/:id -> return serialized metadata (used by preview modal) */
|
||||
router.get("/files/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
|
||||
try {
|
||||
const file = await storage.getFile(id);
|
||||
if (!file) return sendError(res, 404, "File not found");
|
||||
return res.json({ error: false, data: serializeFile(file as any) });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Failed to load file");
|
||||
}
|
||||
});
|
||||
|
||||
/* GET /files/:id/content -> stream file with inline disposition for preview */
|
||||
router.get(
|
||||
"/files/:id/content",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
|
||||
try {
|
||||
const file = await storage.getFile(id);
|
||||
if (!file) return sendError(res, 404, "File not found");
|
||||
|
||||
const filename = (file.name ?? `file-${(file as any).id}`).replace(
|
||||
/["\\]/g,
|
||||
""
|
||||
);
|
||||
|
||||
if ((file as any).mimeType)
|
||||
res.setHeader("Content-Type", (file as any).mimeType);
|
||||
// NOTE: inline instead of attachment so browser can render (images, pdfs)
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${encodeURIComponent(filename)}"`
|
||||
);
|
||||
|
||||
await storage.streamFileTo(res, id);
|
||||
|
||||
if (!res.writableEnded) res.end();
|
||||
} catch (err) {
|
||||
if (res.headersSent) return res.end();
|
||||
return sendError(res, 500, "Failed to stream file");
|
||||
}
|
||||
}
|
||||
);
|
||||
/* GET /files/:id/download */
|
||||
router.get(
|
||||
"/files/:id/download",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||
if (!Number.isInteger(id) || id <= 0)
|
||||
return sendError(res, 400, "Invalid file id");
|
||||
|
||||
try {
|
||||
const file = await storage.getFile(id);
|
||||
if (!file) return sendError(res, 404, "File not found");
|
||||
|
||||
const filename = (file.name ?? `file-${(file as any).id}`).replace(
|
||||
/["\\]/g,
|
||||
""
|
||||
);
|
||||
if ((file as any).mimeType)
|
||||
res.setHeader("Content-Type", (file as any).mimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(filename)}"`
|
||||
);
|
||||
|
||||
await storage.streamFileTo(res, id);
|
||||
|
||||
if (!res.writableEnded) res.end();
|
||||
} catch (err) {
|
||||
if (res.headersSent) return res.end();
|
||||
return sendError(res, 500, "Failed to stream file");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ---------- Search endpoints (separate) ----------
|
||||
GET /search/folders?q=&limit=&offset=
|
||||
GET /search/files?q=&type=&limit=&offset=
|
||||
*/
|
||||
router.get(
|
||||
"/search/folders",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const q = String(req.query.q ?? "").trim();
|
||||
const limit = parsePositiveInt(req.query.limit, 20);
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
if (!q) return sendError(res, 400, "Missing search query parameter 'q'");
|
||||
|
||||
try {
|
||||
const parentId = null;
|
||||
|
||||
const { data, total } = await storage.searchFolders(
|
||||
q,
|
||||
limit,
|
||||
offset,
|
||||
parentId
|
||||
);
|
||||
return res.json({ error: false, data, totalCount: total });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "Folder search failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/search/files",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const q = String(req.query.q ?? "").trim();
|
||||
const type =
|
||||
typeof req.query.type === "string" ? req.query.type.trim() : undefined;
|
||||
const limit = parsePositiveInt(req.query.limit, 20);
|
||||
const offset = parsePositiveInt(req.query.offset, 0);
|
||||
if (!q && !type)
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
"Provide at least one of 'q' or 'type' to search files"
|
||||
);
|
||||
|
||||
try {
|
||||
const { data, total } = await storage.searchFiles(q, type, limit, offset);
|
||||
const serialized = data.map(serializeFile);
|
||||
return res.json({ error: false, data: serialized, totalCount: total });
|
||||
} catch (err) {
|
||||
return sendError(res, 500, "File search failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
360
apps/Backend/src/routes/database-management.ts
Executable file
360
apps/Backend/src/routes/database-management.ts
Executable file
@@ -0,0 +1,360 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
import archiver from "archiver";
|
||||
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Create a database backup
|
||||
*
|
||||
* - Uses pg_dump in directory format for parallel dump to a tmp dir
|
||||
* - Uses 'archiver' to create zip or gzipped tar stream directly to response
|
||||
* - Supports explicit override via BACKUP_ARCHIVE_FORMAT env var ('zip' or 'tar')
|
||||
* - Ensures cleanup of tmp dir on success/error/client disconnect
|
||||
*/
|
||||
|
||||
// helper to remove directory (sync to keep code straightforward)
|
||||
function safeRmDir(dir: string) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(userId);
|
||||
|
||||
// create a unique tmp directory for directory-format dump
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
||||
|
||||
// Decide archive format
|
||||
// BACKUP_ARCHIVE_FORMAT can be 'zip' or 'tar' (case-insensitive)
|
||||
const forced = (process.env.BACKUP_ARCHIVE_FORMAT || "").toLowerCase();
|
||||
const useZip =
|
||||
forced === "zip"
|
||||
? true
|
||||
: forced === "tar"
|
||||
? false
|
||||
: process.platform === "win32";
|
||||
|
||||
const filename = useZip
|
||||
? `dental_backup_${Date.now()}.zip`
|
||||
: `dental_backup_${Date.now()}.tar.gz`;
|
||||
|
||||
// Spawn pg_dump
|
||||
const pgDump = spawn(
|
||||
"pg_dump",
|
||||
[
|
||||
"-Fd", // DIRECTORY format (required for parallel dump)
|
||||
"-j",
|
||||
"4", // number of parallel jobs — MUST be >0 for parallelism
|
||||
"--no-acl",
|
||||
"--no-owner",
|
||||
"-h",
|
||||
process.env.DB_HOST || "localhost",
|
||||
"-U",
|
||||
process.env.DB_USER || "postgres",
|
||||
process.env.DB_NAME || "dental_db",
|
||||
"-f",
|
||||
tmpDir, // write parallely
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.DB_PASSWORD,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let pgStderr = "";
|
||||
pgDump.stderr.on("data", (chunk) => {
|
||||
pgStderr += chunk.toString();
|
||||
});
|
||||
|
||||
pgDump.on("error", (err) => {
|
||||
safeRmDir(tmpDir);
|
||||
console.error("Failed to start pg_dump:", err);
|
||||
// If headers haven't been sent, respond; otherwise just end socket
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to run pg_dump", details: err.message });
|
||||
} else {
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
pgDump.on("close", async (code) => {
|
||||
if (code !== 0) {
|
||||
safeRmDir(tmpDir);
|
||||
console.error("pg_dump failed:", pgStderr || `exit ${code}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: "Backup failed",
|
||||
details: pgStderr || `pg_dump exited with ${code}`,
|
||||
});
|
||||
} else {
|
||||
// headers already sent — destroy response
|
||||
res.destroy(new Error("pg_dump failed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// pg_dump succeeded — stream archive directly to response using archiver
|
||||
// Set headers before piping
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename}"`
|
||||
);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
useZip ? "application/zip" : "application/gzip"
|
||||
);
|
||||
|
||||
const archive = archiver(
|
||||
useZip ? "zip" : "tar",
|
||||
useZip ? {} : { gzip: true, gzipOptions: { level: 6 } }
|
||||
);
|
||||
|
||||
let archErr: string | null = null;
|
||||
archive.on("error", (err) => {
|
||||
archErr = err.message;
|
||||
console.error("Archiver error:", err);
|
||||
// attempt to respond with error if possible
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Failed to create archive",
|
||||
details: err.message,
|
||||
});
|
||||
} else {
|
||||
// if streaming already started, destroy the connection
|
||||
res.destroy(err);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow
|
||||
} finally {
|
||||
safeRmDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// If client disconnects while streaming
|
||||
res.once("close", () => {
|
||||
// destroy archiver (stop processing) and cleanup tmpDir
|
||||
try {
|
||||
archive.destroy();
|
||||
} catch (e) {}
|
||||
safeRmDir(tmpDir);
|
||||
});
|
||||
|
||||
// When streaming finishes successfully
|
||||
res.once("finish", async () => {
|
||||
// cleanup the tmp dir used by pg_dump
|
||||
safeRmDir(tmpDir);
|
||||
|
||||
// update metadata (try/catch so it won't break response flow)
|
||||
try {
|
||||
await storage.createBackup(userId);
|
||||
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||
} catch (err) {
|
||||
console.error("Backup saved but metadata update failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe archive into response
|
||||
archive.pipe(res);
|
||||
|
||||
// Add the dumped directory contents to the archive root
|
||||
// `directory(source, dest)` where dest is false/'' to place contents at archive root
|
||||
archive.directory(tmpDir + path.sep, false);
|
||||
|
||||
// finalize archive (this starts streaming)
|
||||
try {
|
||||
await archive.finalize();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to finalize archive:", err);
|
||||
// if headers not sent, send 500; otherwise destroy
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Failed to finalize archive",
|
||||
details: String(err),
|
||||
});
|
||||
} else {
|
||||
res.destroy(err);
|
||||
}
|
||||
} catch (e) {}
|
||||
safeRmDir(tmpDir);
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Unexpected error in /backup:", err);
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Internal server error", details: String(err) });
|
||||
} else {
|
||||
res.destroy(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get database status (connected, size, records count)
|
||||
*/
|
||||
router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const size = await prisma.$queryRaw<{ size: string }[]>`
|
||||
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||
`;
|
||||
|
||||
const patientsCount = await storage.getTotalPatientCount();
|
||||
const lastBackup = await storage.getLastBackup(userId);
|
||||
|
||||
res.json({
|
||||
connected: true,
|
||||
size: size[0]?.size,
|
||||
patients: patientsCount,
|
||||
lastBackup: lastBackup?.createdAt ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Status error:", err);
|
||||
res.status(500).json({
|
||||
connected: false,
|
||||
error: "Could not fetch database status",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==============================
|
||||
// Backup Destination CRUD
|
||||
// ==============================
|
||||
|
||||
// CREATE / UPDATE destination
|
||||
router.post("/destination", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
const { path: destinationPath } = req.body;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
if (!destinationPath)
|
||||
return res.status(400).json({ error: "Path is required" });
|
||||
|
||||
// validate path exists
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
return res.status(400).json({
|
||||
error: "Backup path does not exist or drive not connected",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = await storage.createBackupDestination(
|
||||
userId,
|
||||
destinationPath
|
||||
);
|
||||
res.json(destination);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to save backup destination" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET all destinations
|
||||
router.get("/destination", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const destinations = await storage.getAllBackupDestination(userId);
|
||||
res.json(destinations);
|
||||
});
|
||||
|
||||
// UPDATE destination
|
||||
router.put("/destination/:id", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
const id = Number(req.params.id);
|
||||
const { path: destinationPath } = req.body;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
if (!destinationPath)
|
||||
return res.status(400).json({ error: "Path is required" });
|
||||
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
return res.status(400).json({ error: "Path does not exist" });
|
||||
}
|
||||
|
||||
const updated = await storage.updateBackupDestination(
|
||||
id,
|
||||
userId,
|
||||
destinationPath
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE destination
|
||||
router.delete("/destination/:id", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
await storage.deleteBackupDestination(id, userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post("/backup-path", async (req, res) => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(userId);
|
||||
if (!destination) {
|
||||
return res.status(400).json({
|
||||
error: "No backup destination configured",
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(destination.path)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Backup destination not found. External drive may be disconnected.",
|
||||
});
|
||||
}
|
||||
|
||||
const filename = `dental_backup_${Date.now()}.zip`;
|
||||
|
||||
try {
|
||||
await backupDatabaseToPath({
|
||||
destinationPath: destination.path,
|
||||
filename,
|
||||
});
|
||||
|
||||
await storage.createBackup(userId);
|
||||
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||
|
||||
res.json({ success: true, filename });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
res.status(500).json({
|
||||
error: "Backup to destination failed",
|
||||
details: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
423
apps/Backend/src/routes/documents.ts
Executable file
423
apps/Backend/src/routes/documents.ts
Executable file
@@ -0,0 +1,423 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import multer from "multer";
|
||||
import { PdfFile } from "../../../../packages/db/types/pdf-types";
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ----------- PDF GROUPS ------------------
|
||||
router.post(
|
||||
"/pdf-groups",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { patientId, groupTitle, groupTitleKey } = req.body;
|
||||
if (!patientId || !groupTitle || groupTitleKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing title, titleKey, or patientId" });
|
||||
}
|
||||
|
||||
const group = await storage.createPdfGroup(
|
||||
parseInt(patientId),
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
|
||||
res.json(group);
|
||||
} catch (err) {
|
||||
console.error("Error creating PDF group:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/pdf-groups/patient/:patientId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { patientId } = req.params;
|
||||
if (!patientId) {
|
||||
return res.status(400).json({ error: "Missing patient ID" });
|
||||
}
|
||||
|
||||
const groups = await storage.getPdfGroupsByPatientId(parseInt(patientId));
|
||||
res.json(groups);
|
||||
} catch (err) {
|
||||
console.error("Error fetching groups by patient ID:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/pdf-groups/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam);
|
||||
const group = await storage.getPdfGroupById(id);
|
||||
if (!group) return res.status(404).json({ error: "Group not found" });
|
||||
res.json(group);
|
||||
} catch (err) {
|
||||
console.error("Error fetching PDF group:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/pdf-groups", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const groups = await storage.getAllPdfGroups();
|
||||
res.json(groups);
|
||||
} catch (err) {
|
||||
console.error("Error listing PDF groups:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/pdf-groups/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam);
|
||||
const { title, titleKey } = req.body;
|
||||
|
||||
const updates: any = {};
|
||||
updates.title = title;
|
||||
updates.titleKey = titleKey;
|
||||
|
||||
const updated = await storage.updatePdfGroup(id, updates);
|
||||
if (!updated) return res.status(404).json({ error: "Group not found" });
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error("Error updating PDF group:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/pdf-groups/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam);
|
||||
const success = await storage.deletePdfGroup(id);
|
||||
res.json({ success });
|
||||
} catch (err) {
|
||||
console.error("Error deleting PDF group:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------- PDF FILES ------------------
|
||||
router.post(
|
||||
"/pdf-files",
|
||||
upload.single("file"),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { groupId } = req.body;
|
||||
const file = req.file;
|
||||
if (!groupId || !file) {
|
||||
return res.status(400).json({ error: "Missing groupId or file" });
|
||||
}
|
||||
|
||||
const pdf = await storage.createPdfFile(
|
||||
parseInt(groupId),
|
||||
file.originalname,
|
||||
file.buffer
|
||||
);
|
||||
|
||||
res.json(pdf);
|
||||
} catch (err) {
|
||||
console.error("Error uploading PDF file:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/pdf-files/group/:groupId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.groupId;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing Groupt ID" });
|
||||
}
|
||||
const groupId = parseInt(idParam);
|
||||
const files = await storage.getPdfFilesByGroupId(groupId);
|
||||
res.json(files);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /pdf-files/group/:groupId
|
||||
* Query params:
|
||||
* - limit (optional, defaults to 5): number of items per page (max 1000)
|
||||
* - offset (optional, defaults to 0): offset for pagination
|
||||
*
|
||||
* Response: { total: number, data: PdfFile[] }
|
||||
*/
|
||||
router.get(
|
||||
"/recent-pdf-files/group/:groupId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const rawGroupId = req.params.groupId;
|
||||
if (!rawGroupId) {
|
||||
return res.status(400).json({ error: "Missing groupId param" });
|
||||
}
|
||||
|
||||
const groupId = Number(rawGroupId);
|
||||
if (Number.isNaN(groupId) || groupId <= 0) {
|
||||
return res.status(400).json({ error: "Invalid groupId" });
|
||||
}
|
||||
|
||||
// Parse & sanitize query params
|
||||
const limitQuery = req.query.limit;
|
||||
const offsetQuery = req.query.offset;
|
||||
|
||||
const limit =
|
||||
limitQuery !== undefined
|
||||
? Math.min(Math.max(Number(limitQuery), 1), 1000) // 1..1000
|
||||
: undefined; // if undefined -> treat as "no pagination" (return all)
|
||||
const offset =
|
||||
offsetQuery !== undefined ? Math.max(Number(offsetQuery), 0) : 0;
|
||||
|
||||
// Decide whether client asked for paginated response
|
||||
const wantsPagination = typeof limit === "number";
|
||||
|
||||
if (wantsPagination) {
|
||||
// storage.getPdfFilesByGroupId with pagination should return { total, data }
|
||||
const result = await storage.getPdfFilesByGroupId(groupId, {
|
||||
limit,
|
||||
offset,
|
||||
withGroup: false, // do not include group relation in listing
|
||||
});
|
||||
|
||||
// result should be { total, data }, but handle unexpected shapes defensively
|
||||
if (Array.isArray(result)) {
|
||||
// fallback: storage returned full array; compute total
|
||||
return res.json({ total: result.length, data: result });
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} else {
|
||||
// no limit requested -> return all files for the group
|
||||
const all = (await storage.getPdfFilesByGroupId(groupId)) as PdfFile[];
|
||||
return res.json({ total: all.length, data: all });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("GET /pdf-files/group/:groupId error:", err);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/pdf-files/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
const pdf = await storage.getPdfFileById(id);
|
||||
if (!pdf || !pdf.pdfData) {
|
||||
return res.status(404).json({ error: "PDF not found" });
|
||||
}
|
||||
|
||||
const data: any = pdf.pdfData;
|
||||
|
||||
// Helper: try many plausible conversions into a Buffer
|
||||
function normalizeToBuffer(d: any): Buffer | null {
|
||||
// Already a Buffer
|
||||
if (Buffer.isBuffer(d)) return d;
|
||||
|
||||
// Uint8Array or other typed arrays
|
||||
if (d instanceof Uint8Array) return Buffer.from(d);
|
||||
|
||||
// ArrayBuffer
|
||||
if (d instanceof ArrayBuffer) return Buffer.from(new Uint8Array(d));
|
||||
|
||||
// number[] (common)
|
||||
if (Array.isArray(d) && d.every((n) => typeof n === "number")) {
|
||||
return Buffer.from(d as number[]);
|
||||
}
|
||||
|
||||
// Some drivers: { data: number[] }
|
||||
if (
|
||||
d &&
|
||||
typeof d === "object" &&
|
||||
Array.isArray(d.data) &&
|
||||
d.data.every((n: any) => typeof n === "number")
|
||||
) {
|
||||
return Buffer.from(d.data as number[]);
|
||||
}
|
||||
|
||||
// Some drivers return object with numeric keys: { '0': 37, '1': 80, ... }
|
||||
if (d && typeof d === "object") {
|
||||
const keys = Object.keys(d);
|
||||
const numericKeys = keys.filter((k) => /^\d+$/.test(k));
|
||||
if (numericKeys.length > 0 && numericKeys.length === keys.length) {
|
||||
// sort numeric keys to correct order and map to numbers
|
||||
const sorted = numericKeys
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => d[String(n)]);
|
||||
if (sorted.every((v) => typeof v === "number")) {
|
||||
return Buffer.from(sorted as number[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: if Object.values(d) yields numbers (this is what you used originally)
|
||||
try {
|
||||
const vals = Object.values(d);
|
||||
if (Array.isArray(vals) && vals.every((v) => typeof v === "number")) {
|
||||
// coerce to number[] for TS safety
|
||||
return Buffer.from(vals as number[]);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// give up
|
||||
return null;
|
||||
}
|
||||
|
||||
const pdfBuffer = normalizeToBuffer(data);
|
||||
|
||||
if (!pdfBuffer) {
|
||||
console.error("Unsupported pdf.pdfData shape:", {
|
||||
typeofData: typeof data,
|
||||
constructorName:
|
||||
data && data.constructor ? data.constructor.name : undefined,
|
||||
keys:
|
||||
data && typeof data === "object"
|
||||
? Object.keys(data).slice(0, 20)
|
||||
: undefined,
|
||||
sample: (() => {
|
||||
if (Array.isArray(data)) return data.slice(0, 20);
|
||||
if (data && typeof data === "object") {
|
||||
const vals = Object.values(data);
|
||||
return Array.isArray(vals) ? vals.slice(0, 20) : undefined;
|
||||
}
|
||||
return String(data).slice(0, 200);
|
||||
})(),
|
||||
});
|
||||
|
||||
// Try a safe textual fallback (may produce invalid PDF but avoids crashing)
|
||||
try {
|
||||
const fallback = Buffer.from(String(data));
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${pdf.filename}"; filename*=UTF-8''${encodeURIComponent(pdf.filename)}`
|
||||
);
|
||||
return res.send(fallback);
|
||||
} catch (err) {
|
||||
console.error("Failed fallback conversion:", err);
|
||||
return res.status(500).json({ error: "Cannot process PDF data" });
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${pdf.filename}"; filename*=UTF-8''${encodeURIComponent(pdf.filename)}`
|
||||
);
|
||||
res.send(pdfBuffer);
|
||||
} catch (err) {
|
||||
console.error("Error downloading PDF file:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/pdf-files/recent",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 5;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const files = await storage.getRecentPdfFiles(limit, offset);
|
||||
res.json(files);
|
||||
} catch (err) {
|
||||
console.error("Error getting recent PDF files:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/pdf-files/:id",
|
||||
upload.single("file"),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam);
|
||||
const file = req.file;
|
||||
|
||||
const updated = await storage.updatePdfFile(id, {
|
||||
filename: file?.originalname,
|
||||
pdfData: file?.buffer,
|
||||
});
|
||||
|
||||
if (!updated)
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "PDF not found or update failed" });
|
||||
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error("Error updating PDF file:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/pdf-files/:id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) {
|
||||
return res.status(400).json({ error: "Missing ID" });
|
||||
}
|
||||
const id = parseInt(idParam);
|
||||
|
||||
const success = await storage.deletePdfFile(id);
|
||||
res.json({ success });
|
||||
} catch (err) {
|
||||
console.error("Error deleting PDF file:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
99
apps/Backend/src/routes/export-payments-reports.ts
Executable file
99
apps/Backend/src/routes/export-payments-reports.ts
Executable file
@@ -0,0 +1,99 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/reports/export
|
||||
* query:
|
||||
* - type = patients_with_balance | collections_by_doctor
|
||||
* - from, to = optional ISO date strings (YYYY-MM-DD)
|
||||
* - staffId = required for collections_by_doctor
|
||||
* - format = csv (we expect csv; if missing default to csv)
|
||||
*/
|
||||
function escapeCsvCell(v: any) {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = String(v).replace(/\r?\n/g, " ");
|
||||
if (s.includes('"') || s.includes(",") || s.includes("\n")) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
router.get("/export", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const type = String(req.query.type || "");
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||
const staffId = req.query.staffId ? Number(req.query.staffId) : undefined;
|
||||
const format = String(req.query.format || "csv").toLowerCase();
|
||||
|
||||
if (format !== "csv") {
|
||||
return res.status(400).json({ message: "Only CSV export is supported" });
|
||||
}
|
||||
|
||||
let patientsSummary: any[] = [];
|
||||
|
||||
if (type === "patients_with_balance") {
|
||||
patientsSummary = await storage.fetchAllPatientsWithBalances(from, to);
|
||||
} else if (type === "collections_by_doctor") {
|
||||
if (!staffId || !Number.isFinite(staffId) || staffId <= 0) {
|
||||
return res.status(400).json({ message: "Missing or invalid staffId for collections_by_doctor" });
|
||||
}
|
||||
patientsSummary = await storage.fetchAllPatientsForDoctor(staffId, from, to);
|
||||
} else {
|
||||
return res.status(400).json({ message: "Unsupported report type" });
|
||||
}
|
||||
|
||||
const patientsWithFinancials = await storage.buildExportRowsForPatients(patientsSummary, 5000);
|
||||
|
||||
// Build CSV - flattened rows
|
||||
// columns: patientId, patientName, currentBalance, type, date, procedureCode, billed, paid, adjusted, totalDue, status
|
||||
const header = [
|
||||
"patientId",
|
||||
"patientName",
|
||||
"currentBalance",
|
||||
"type",
|
||||
"date",
|
||||
"procedureCode",
|
||||
"billed",
|
||||
"paid",
|
||||
"adjusted",
|
||||
"totalDue",
|
||||
"status",
|
||||
];
|
||||
|
||||
const lines = [header.join(",")];
|
||||
|
||||
for (const p of patientsWithFinancials) {
|
||||
const name = `${p.firstName ?? ""} ${p.lastName ?? ""}`.trim();
|
||||
for (const fr of p.financialRows) {
|
||||
lines.push(
|
||||
[
|
||||
escapeCsvCell(p.patientId),
|
||||
escapeCsvCell(name),
|
||||
(Number(p.currentBalance ?? 0)).toFixed(2),
|
||||
escapeCsvCell(fr.type),
|
||||
escapeCsvCell(fr.date),
|
||||
escapeCsvCell(fr.procedureCode),
|
||||
(Number(fr.billed ?? 0)).toFixed(2),
|
||||
(Number(fr.paid ?? 0)).toFixed(2),
|
||||
(Number(fr.adjusted ?? 0)).toFixed(2),
|
||||
(Number(fr.totalDue ?? 0)).toFixed(2),
|
||||
escapeCsvCell(fr.status),
|
||||
].join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fname = `report-${type}-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${fname}"`);
|
||||
return res.send(lines.join("\n"));
|
||||
} catch (err: any) {
|
||||
console.error("[/api/reports/export] error:", err?.message ?? err, err?.stack);
|
||||
return res.status(500).json({ message: "Export error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
apps/Backend/src/routes/index.ts
Executable file
46
apps/Backend/src/routes/index.ts
Executable file
@@ -0,0 +1,46 @@
|
||||
import { Router } from "express";
|
||||
import patientsRoutes from "./patients";
|
||||
import appointmentsRoutes from "./appointments";
|
||||
import appointmentProceduresRoutes from "./appointments-procedures";
|
||||
import usersRoutes from "./users";
|
||||
import staffsRoutes from "./staffs";
|
||||
import npiProvidersRoutes from "./npiProviders";
|
||||
import claimsRoutes from "./claims";
|
||||
import patientDataExtractionRoutes from "./patientDataExtraction";
|
||||
import insuranceCredsRoutes from "./insuranceCreds";
|
||||
import documentsRoutes from "./documents";
|
||||
import patientDocumentsRoutes from "./patient-documents";
|
||||
import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
import paymentOcrRoutes from "./paymentOcrExtraction";
|
||||
import cloudStorageRoutes from "./cloud-storage";
|
||||
import paymentsReportsRoutes from "./payments-reports";
|
||||
import exportPaymentsReportsRoutes from "./export-payments-reports";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use("/patients", patientsRoutes);
|
||||
router.use("/appointments", appointmentsRoutes);
|
||||
router.use("/appointment-procedures", appointmentProceduresRoutes);
|
||||
router.use("/users", usersRoutes);
|
||||
router.use("/staffs", staffsRoutes);
|
||||
router.use("/npiProviders", npiProvidersRoutes);
|
||||
router.use("/patientDataExtraction", patientDataExtractionRoutes);
|
||||
router.use("/claims", claimsRoutes);
|
||||
router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||
router.use("/documents", documentsRoutes);
|
||||
router.use("/patient-documents", patientDocumentsRoutes);
|
||||
router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
router.use("/payment-ocr", paymentOcrRoutes);
|
||||
router.use("/cloud-storage", cloudStorageRoutes);
|
||||
router.use("/payments-reports", paymentsReportsRoutes);
|
||||
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
|
||||
|
||||
export default router;
|
||||
126
apps/Backend/src/routes/insuranceCreds.ts
Executable file
126
apps/Backend/src/routes/insuranceCreds.ts
Executable file
@@ -0,0 +1,126 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
insertInsuranceCredentialSchema,
|
||||
InsuranceCredential,
|
||||
} from "@repo/db/types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ✅ Get all credentials for a user
|
||||
router.get("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ message: "Unauthorized: user info missing" });
|
||||
}
|
||||
const userId = req.user.id;
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialsByUser(userId);
|
||||
return res.status(200).json(credentials);
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch credentials", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Create credential for a user
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ message: "Unauthorized: user info missing" });
|
||||
}
|
||||
const userId = req.user.id;
|
||||
|
||||
const parseResult = insertInsuranceCredentialSchema.safeParse({
|
||||
...req.body,
|
||||
userId,
|
||||
});
|
||||
if (!parseResult.success) {
|
||||
const flat = (
|
||||
parseResult as typeof parseResult & { error: z.ZodError<any> }
|
||||
).error.flatten();
|
||||
const firstError =
|
||||
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
|
||||
|
||||
return res.status(400).json({
|
||||
message: firstError,
|
||||
details: flat.fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await storage.createInsuranceCredential(
|
||||
parseResult.data
|
||||
);
|
||||
return res.status(201).json(credential);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
return res.status(400).json({
|
||||
message: `Credential with this ${err.meta?.target?.join(", ")} already exists.`,
|
||||
});
|
||||
}
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to create credential", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Update credential
|
||||
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid credential ID");
|
||||
|
||||
const updates = req.body as Partial<InsuranceCredential>;
|
||||
const credential = await storage.updateInsuranceCredential(id, updates);
|
||||
return res.status(200).json(credential);
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update credential", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Delete a credential
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||
|
||||
// 1) Check existence
|
||||
const existing = await storage.getInsuranceCredential(id);
|
||||
if (!existing)
|
||||
return res.status(404).json({ message: "Credential not found" });
|
||||
|
||||
// 2) Ownership check
|
||||
if (existing.userId !== userId) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Credentials belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Delete (storage method enforces userId + id)
|
||||
const ok = await storage.deleteInsuranceCredential(userId, id);
|
||||
if (!ok) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ message: "Credential not found or already deleted" });
|
||||
}
|
||||
return res.status(204).send();
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to delete credential", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
844
apps/Backend/src/routes/insuranceStatus.ts
Executable file
844
apps/Backend/src/routes/insuranceStatus.ts
Executable file
@@ -0,0 +1,844 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { forwardToSeleniumInsuranceEligibilityAgent } from "../services/seleniumInsuranceEligibilityClient";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import PDFDocument from "pdfkit";
|
||||
import { forwardToSeleniumInsuranceClaimStatusAgent } from "../services/seleniumInsuranceClaimStatusClient";
|
||||
import fsSync from "fs";
|
||||
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||
import forwardToPatientDataExtractorService from "../services/patientDataExtractorService";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
} from "../../../../packages/db/types/patient-types";
|
||||
import { formatDobForAgent } from "../utils/dateUtils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Utility: naive name splitter */
|
||||
function splitName(fullName?: string | null) {
|
||||
if (!fullName) return { firstName: "", lastName: "" };
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
const firstName = parts.shift() ?? "";
|
||||
const lastName = parts.join(" ") ?? "";
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure patient exists for given insuranceId.
|
||||
* If exists -> update first/last name when different.
|
||||
* If not -> create using provided fields.
|
||||
* Returns the patient object (the version read from DB after potential create/update).
|
||||
*/
|
||||
async function createOrUpdatePatientByInsuranceId(options: {
|
||||
insuranceId: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
dob?: string | Date | null;
|
||||
userId: number;
|
||||
}) {
|
||||
const { insuranceId, firstName, lastName, dob, userId } = options;
|
||||
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||
|
||||
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
|
||||
// Normalize incoming names
|
||||
const incomingFirst = firstName?.trim() ?? "";
|
||||
const incomingLast = lastName?.trim() ?? "";
|
||||
|
||||
if (patient && patient.id) {
|
||||
// update only if different
|
||||
const updates: any = {};
|
||||
if (
|
||||
incomingFirst &&
|
||||
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||
) {
|
||||
updates.firstName = incomingFirst;
|
||||
}
|
||||
if (
|
||||
incomingLast &&
|
||||
String(patient.lastName ?? "").trim() !== incomingLast
|
||||
) {
|
||||
updates.lastName = incomingLast;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
// Refetch to get updated data
|
||||
patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
}
|
||||
return patient;
|
||||
} else {
|
||||
// inside createOrUpdatePatientByInsuranceId, when creating:
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob, // raw from caller (string | Date | null)
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
};
|
||||
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
patientData = insertPatientSchema.parse(createPayload);
|
||||
} catch (err) {
|
||||
// handle malformed dob or other validation errors conservatively
|
||||
console.warn(
|
||||
"Failed to validate patient payload in insurance flow:",
|
||||
err
|
||||
);
|
||||
// either rethrow or drop invalid fields — here we drop dob and proceed
|
||||
const safePayload = { ...createPayload };
|
||||
delete (safePayload as any).dateOfBirth;
|
||||
patientData = insertPatientSchema.parse(safePayload);
|
||||
}
|
||||
|
||||
await storage.createPatient(patientData);
|
||||
// Return the created patient
|
||||
return await storage.getPatientByInsuranceId(insuranceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /eligibility-check
|
||||
* - run selenium
|
||||
* - if pdf created -> call extractor -> get name
|
||||
* - create or update patient (by memberId)
|
||||
* - attach PDF to patient (create pdf group/file)
|
||||
* - return { patient, pdfFileId, extractedName ... }
|
||||
*/
|
||||
router.post(
|
||||
"/eligibility-check",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
let seleniumResult: any = undefined;
|
||||
let createdPdfFileId: number | null = null;
|
||||
let outputResult: any = {};
|
||||
const extracted: any = {};
|
||||
|
||||
try {
|
||||
// const insuranceEligibilityData = JSON.parse(req.body.data);
|
||||
// Handle both string and object data
|
||||
const insuranceEligibilityData = typeof req.body.data === 'string'
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body.data;
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
insuranceEligibilityData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...insuranceEligibilityData,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
|
||||
// 1) Run selenium agent
|
||||
try {
|
||||
seleniumResult =
|
||||
await forwardToSeleniumInsuranceEligibilityAgent(enrichedData);
|
||||
} catch (seleniumErr: any) {
|
||||
return res.status(502).json({
|
||||
error: "Selenium service failed",
|
||||
detail: seleniumErr?.message ?? String(seleniumErr),
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Extract data from selenium result (page extraction) and PDF
|
||||
let extracted: any = {};
|
||||
|
||||
// First, try to get data from selenium's page extraction
|
||||
if (seleniumResult.firstName || seleniumResult.lastName) {
|
||||
extracted.firstName = seleniumResult.firstName || null;
|
||||
extracted.lastName = seleniumResult.lastName || null;
|
||||
console.log('[eligibility-check] Using name from selenium extraction:', {
|
||||
firstName: extracted.firstName,
|
||||
lastName: extracted.lastName
|
||||
});
|
||||
}
|
||||
// Also check for combined name field (fallback)
|
||||
else if (seleniumResult.name) {
|
||||
const parts = splitName(seleniumResult.name);
|
||||
extracted.firstName = parts.firstName;
|
||||
extracted.lastName = parts.lastName;
|
||||
console.log('[eligibility-check] Using combined name from selenium extraction:', parts);
|
||||
}
|
||||
|
||||
// If no name from selenium, try PDF extraction
|
||||
if (!extracted.firstName && !extracted.lastName &&
|
||||
seleniumResult?.pdf_path &&
|
||||
seleniumResult.pdf_path.endsWith(".pdf")
|
||||
) {
|
||||
try {
|
||||
const pdfPath = seleniumResult.pdf_path;
|
||||
console.log('[eligibility-check] Extracting data from PDF:', pdfPath);
|
||||
const pdfBuffer = await fs.readFile(pdfPath);
|
||||
|
||||
const extraction = await forwardToPatientDataExtractorService({
|
||||
buffer: pdfBuffer,
|
||||
originalname: path.basename(pdfPath),
|
||||
mimetype: "application/pdf",
|
||||
} as any);
|
||||
|
||||
console.log('[eligibility-check] PDF Extraction result:', extraction);
|
||||
|
||||
if (extraction.name) {
|
||||
const parts = splitName(extraction.name);
|
||||
extracted.firstName = parts.firstName;
|
||||
extracted.lastName = parts.lastName;
|
||||
console.log('[eligibility-check] Split name from PDF:', parts);
|
||||
} else {
|
||||
console.warn('[eligibility-check] No name extracted from PDF');
|
||||
}
|
||||
} catch (extractErr: any) {
|
||||
console.error('[eligibility-check] Patient data extraction failed:', extractErr);
|
||||
// Continue without extracted names - we'll use form names or create patient with empty names
|
||||
}
|
||||
}
|
||||
|
||||
// Step-3) Create or update patient name using extracted info (prefer extractor -> request)
|
||||
const insuranceId = String(
|
||||
insuranceEligibilityData.memberId ?? ""
|
||||
).trim();
|
||||
if (!insuranceId) {
|
||||
return res.status(400).json({ error: "Missing memberId" });
|
||||
}
|
||||
|
||||
// Always prioritize extracted data from MassHealth over form input
|
||||
// Form input is only used as fallback when extraction fails
|
||||
const preferFirst = extracted.firstName || null;
|
||||
const preferLast = extracted.lastName || null;
|
||||
|
||||
console.log('[eligibility-check] Name priority:', {
|
||||
extracted: { firstName: extracted.firstName, lastName: extracted.lastName },
|
||||
fromForm: { firstName: insuranceEligibilityData.firstName, lastName: insuranceEligibilityData.lastName },
|
||||
using: { firstName: preferFirst, lastName: preferLast }
|
||||
});
|
||||
|
||||
let patient;
|
||||
try {
|
||||
patient = await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName: preferFirst,
|
||||
lastName: preferLast,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: req.user.id,
|
||||
});
|
||||
console.log('[eligibility-check] Patient after create/update:', patient);
|
||||
} catch (patientOpErr: any) {
|
||||
return res.status(500).json({
|
||||
error: "Failed to create/update patient",
|
||||
detail: patientOpErr?.message ?? String(patientOpErr),
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Step 4: Update patient status based on selenium result
|
||||
if (patient && patient.id !== undefined) {
|
||||
// Use eligibility from selenium extraction if available, otherwise default to UNKNOWN
|
||||
let newStatus = "UNKNOWN";
|
||||
|
||||
if (seleniumResult.eligibility === "Y") {
|
||||
newStatus = "ACTIVE";
|
||||
} else if (seleniumResult.eligibility === "N") {
|
||||
newStatus = "INACTIVE";
|
||||
}
|
||||
|
||||
// Prepare updates object
|
||||
const updates: any = { status: newStatus };
|
||||
|
||||
// Update insurance provider if extracted
|
||||
if (seleniumResult.insurance) {
|
||||
updates.insuranceProvider = seleniumResult.insurance;
|
||||
console.log('[eligibility-check] Updating insurance provider:', seleniumResult.insurance);
|
||||
}
|
||||
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}${seleniumResult.insurance ? ', insurance updated' : ''}`;
|
||||
console.log('[eligibility-check] Status updated:', {
|
||||
patientId: patient.id,
|
||||
newStatus,
|
||||
eligibility: seleniumResult.eligibility,
|
||||
insurance: seleniumResult.insurance
|
||||
});
|
||||
|
||||
// ✅ Step 5: Handle PDF Upload
|
||||
if (
|
||||
seleniumResult.pdf_path &&
|
||||
seleniumResult.pdf_path.endsWith(".pdf")
|
||||
) {
|
||||
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
|
||||
|
||||
const groupTitle = "Eligibility Status";
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
patient.id,
|
||||
groupTitleKey
|
||||
);
|
||||
|
||||
// Step 5b: Create group if it doesn’t exist
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
patient.id,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
|
||||
if (!group?.id) {
|
||||
throw new Error("PDF group creation failed: missing group ID");
|
||||
}
|
||||
|
||||
const created = await storage.createPdfFile(
|
||||
group.id,
|
||||
path.basename(seleniumResult.pdf_path),
|
||||
pdfBuffer
|
||||
);
|
||||
|
||||
// created could be { id, filename } or just id, adapt to your storage API.
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
|
||||
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||
}
|
||||
} else {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found or missing ID; no update performed";
|
||||
}
|
||||
|
||||
res.json({
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||
pdfFileId: createdPdfFileId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to forward to selenium agent",
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
if (seleniumResult && seleniumResult.pdf_path) {
|
||||
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||
} else {
|
||||
console.log(`[eligibility-check] no pdf_path available to cleanup`);
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
console.error(
|
||||
`[eligibility-check cleanup failed for ${seleniumResult?.pdf_path}`,
|
||||
cleanupErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/claim-status-check",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing Insurance Status data for selenium" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
let result: any = undefined;
|
||||
|
||||
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({ autoFirstPage: false });
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
// collect data chunks
|
||||
doc.on("data", (chunk: any) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", (err: any) => reject(err));
|
||||
|
||||
const A4_WIDTH = 595.28; // points
|
||||
const A4_HEIGHT = 841.89; // points
|
||||
|
||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||
|
||||
doc.image(imagePath, 0, 0, {
|
||||
fit: [A4_WIDTH, A4_HEIGHT],
|
||||
align: "center",
|
||||
valign: "center",
|
||||
});
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
const insuranceClaimStatusData = JSON.parse(req.body.data);
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
insuranceClaimStatusData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...insuranceClaimStatusData,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
|
||||
result = await forwardToSeleniumInsuranceClaimStatusAgent(enrichedData);
|
||||
|
||||
let createdPdfFileId: number | null = null;
|
||||
|
||||
// ✅ Step 1: Check result
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceClaimStatusData.memberId
|
||||
);
|
||||
|
||||
if (patient && patient.id !== undefined) {
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
|
||||
if (
|
||||
result.ss_path &&
|
||||
(result.ss_path.endsWith(".png") ||
|
||||
result.ss_path.endsWith(".jpg") ||
|
||||
result.ss_path.endsWith(".jpeg"))
|
||||
) {
|
||||
try {
|
||||
// Ensure file exists
|
||||
if (!fsSync.existsSync(result.ss_path)) {
|
||||
throw new Error(`Screenshot file not found: ${result.ss_path}`);
|
||||
}
|
||||
|
||||
// Convert image to PDF buffer
|
||||
pdfBuffer = await imageToPdfBuffer(result.ss_path);
|
||||
|
||||
// Optionally write generated PDF to temp path (so name is available for createPdfFile)
|
||||
const pdfFileName = `claimStatus_${insuranceClaimStatusData.memberId}_${Date.now()}.pdf`;
|
||||
generatedPdfPath = path.join(
|
||||
path.dirname(result.ss_path),
|
||||
pdfFileName
|
||||
);
|
||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||
} catch (err) {
|
||||
console.error("Failed to convert screenshot to PDF:", err);
|
||||
result.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||
}
|
||||
} else {
|
||||
result.pdfUploadStatus =
|
||||
"No valid PDF or screenshot path provided by Selenium; nothing to upload.";
|
||||
}
|
||||
|
||||
if (pdfBuffer && generatedPdfPath) {
|
||||
const groupTitle = "Claim Status";
|
||||
const groupTitleKey = "CLAIM_STATUS";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
patient.id,
|
||||
groupTitleKey
|
||||
);
|
||||
|
||||
// Create group if missing
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
patient.id,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
|
||||
if (!group?.id) {
|
||||
throw new Error("PDF group creation failed: missing group ID");
|
||||
}
|
||||
|
||||
// Use the basename for storage
|
||||
const basename = path.basename(generatedPdfPath);
|
||||
const created = await storage.createPdfFile(
|
||||
group.id,
|
||||
basename,
|
||||
pdfBuffer
|
||||
);
|
||||
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
|
||||
result.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
}
|
||||
} else {
|
||||
result.patientUpdateStatus =
|
||||
"Patient not found or missing ID; no update performed";
|
||||
}
|
||||
|
||||
res.json({
|
||||
pdfUploadStatus: result.pdfUploadStatus,
|
||||
pdfFileId: createdPdfFileId,
|
||||
});
|
||||
return;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to forward to selenium agent",
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
if (result && result.ss_path) {
|
||||
await emptyFolderContainingFile(result.ss_path);
|
||||
} else {
|
||||
console.log(`claim-status-check] no pdf_path available to cleanup`);
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
console.error(
|
||||
`[claim-status-check cleanup failed for ${result?.ss_path}`,
|
||||
cleanupErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/appointments/check-all-eligibilities",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
// Query param: date=YYYY-MM-DD (required)
|
||||
const date = String(req.query.date ?? "").trim();
|
||||
if (!date) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing date query param (YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
// Track any paths that couldn't be cleaned immediately so we can try again at the end
|
||||
const remainingCleanupPaths = new Set<string>();
|
||||
|
||||
try {
|
||||
// 1) fetch appointments for the day (reuse your storage API)
|
||||
const dayAppointments = await storage.getAppointmentsByDateForUser(
|
||||
date,
|
||||
req.user.id
|
||||
);
|
||||
if (!Array.isArray(dayAppointments)) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to load appointments for date" });
|
||||
}
|
||||
|
||||
const results: Array<any> = [];
|
||||
|
||||
// process sequentially so selenium agent / python semaphore isn't overwhelmed
|
||||
for (const apt of dayAppointments) {
|
||||
// For each appointment we keep a per-appointment seleniumResult so we can cleanup its files
|
||||
let seleniumResult: any = undefined;
|
||||
|
||||
const resultItem: any = {
|
||||
appointmentId: apt.id,
|
||||
patientId: apt.patientId ?? null,
|
||||
processed: false,
|
||||
error: null,
|
||||
pdfFileId: null,
|
||||
patientUpdateStatus: null,
|
||||
warning: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// fetch patient record (use getPatient or getPatientById depending on your storage)
|
||||
const patient = apt.patientId
|
||||
? await storage.getPatient(apt.patientId)
|
||||
: null;
|
||||
const memberId = (patient?.insuranceId ?? "").toString().trim();
|
||||
|
||||
// create a readable patient label for error messages
|
||||
const patientLabel = patient
|
||||
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() ||
|
||||
`patient#${patient.id}`
|
||||
: `patient#${apt.patientId ?? "unknown"}`;
|
||||
|
||||
const aptLabel = `appointment#${apt.id}${apt.date ? ` (${apt.date}${apt.startTime ? ` ${apt.startTime}` : ""})` : ""}`;
|
||||
|
||||
if (!memberId) {
|
||||
resultItem.error = `Missing insuranceId for ${patientLabel} — skipping ${aptLabel}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// prepare eligibility data; prefer patient DOB + name if present
|
||||
const dob = patient?.dateOfBirth;
|
||||
if (!dob) {
|
||||
resultItem.error = `Missing dob for ${patientLabel} — skipping ${aptLabel}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert Date object → YYYY-MM-DD string - req for selenium agent.
|
||||
const dobStr = formatDobForAgent(dob);
|
||||
if (!dobStr) {
|
||||
resultItem.error = `Invalid or missing DOB for ${patientLabel} — skipping ${aptLabel}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: dobStr,
|
||||
insuranceSiteKey: "MH",
|
||||
};
|
||||
|
||||
// Get credentials for this user+site
|
||||
const credentials =
|
||||
await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
payload.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
resultItem.error = `No insurance credentials found for siteKey — skipping ${aptLabel} for ${patientLabel}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// enrich payload
|
||||
const enriched = {
|
||||
...payload,
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
};
|
||||
|
||||
// forward to selenium agent (sequential)
|
||||
try {
|
||||
seleniumResult =
|
||||
await forwardToSeleniumInsuranceEligibilityAgent(enriched);
|
||||
} catch (seleniumErr: any) {
|
||||
resultItem.error = `Selenium agent failed for ${patientLabel} (${aptLabel}): ${seleniumErr?.message ?? String(seleniumErr)}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt extraction (if pdf_path present)
|
||||
const extracted: any = {};
|
||||
if (
|
||||
seleniumResult?.pdf_path &&
|
||||
seleniumResult.pdf_path.endsWith(".pdf")
|
||||
) {
|
||||
try {
|
||||
const pdfPath = seleniumResult.pdf_path;
|
||||
const pdfBuffer = await fs.readFile(pdfPath);
|
||||
|
||||
const extraction = await forwardToPatientDataExtractorService({
|
||||
buffer: pdfBuffer,
|
||||
originalname: path.basename(pdfPath),
|
||||
mimetype: "application/pdf",
|
||||
} as any);
|
||||
|
||||
if (extraction.name) {
|
||||
const parts = splitName(extraction.name);
|
||||
extracted.firstName = parts.firstName;
|
||||
extracted.lastName = parts.lastName;
|
||||
}
|
||||
} catch (extractErr: any) {
|
||||
resultItem.warning = `Extraction failed: ${extractErr?.message ?? String(extractErr)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// create or update patient by insuranceId — prefer extracted name
|
||||
const preferFirst = extracted.firstName ?? null;
|
||||
const preferLast = extracted.lastName ?? null;
|
||||
try {
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId: memberId,
|
||||
firstName: preferFirst,
|
||||
lastName: preferLast,
|
||||
dob: payload.dateOfBirth,
|
||||
userId: req.user.id,
|
||||
});
|
||||
} catch (patientOpErr: any) {
|
||||
resultItem.error = `Failed to create/update patient ${patientLabel} for ${aptLabel}: ${patientOpErr?.message ?? String(patientOpErr)}`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// fetch patient again
|
||||
const updatedPatient =
|
||||
await storage.getPatientByInsuranceId(memberId);
|
||||
if (!updatedPatient || !updatedPatient.id) {
|
||||
resultItem.error = `Patient not found after create/update for ${patientLabel} (${aptLabel})`;
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update patient status based on seleniumResult.eligibility
|
||||
const newStatus =
|
||||
seleniumResult?.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
|
||||
|
||||
// 1. updating patient
|
||||
await storage.updatePatient(updatedPatient.id, { status: newStatus });
|
||||
resultItem.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// 2. updating appointment status - for aptmnt page
|
||||
try {
|
||||
await storage.updateAppointment(Number(apt.id), {
|
||||
eligibilityStatus: newStatus,
|
||||
});
|
||||
resultItem.appointmentUpdateStatus = `Appointment eligibility set to ${newStatus}`;
|
||||
} catch (apptUpdateErr: any) {
|
||||
resultItem.warning =
|
||||
(resultItem.warning ? resultItem.warning + " | " : "") +
|
||||
`Failed to update appointment eligibility: ${apptUpdateErr?.message ?? String(apptUpdateErr)}`;
|
||||
}
|
||||
|
||||
// If PDF exists, upload to PdfGroup (ELIGIBILITY_STATUS)
|
||||
if (
|
||||
seleniumResult?.pdf_path &&
|
||||
seleniumResult.pdf_path.endsWith(".pdf")
|
||||
) {
|
||||
try {
|
||||
const pdfBuf = await fs.readFile(seleniumResult.pdf_path);
|
||||
const groupTitle = "Eligibility Status";
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
updatedPatient.id,
|
||||
groupTitleKey
|
||||
);
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
updatedPatient.id,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
if (!group?.id)
|
||||
throw new Error("Failed to create/find pdf group");
|
||||
|
||||
const created = await storage.createPdfFile(
|
||||
group.id,
|
||||
path.basename(seleniumResult.pdf_path),
|
||||
pdfBuf
|
||||
);
|
||||
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
resultItem.pdfFileId = Number(created.id);
|
||||
} else if (typeof created === "number") {
|
||||
resultItem.pdfFileId = created;
|
||||
} else if (created && (created as any).id) {
|
||||
resultItem.pdfFileId = (created as any).id;
|
||||
}
|
||||
|
||||
resultItem.processed = true;
|
||||
} catch (pdfErr: any) {
|
||||
resultItem.warning = `PDF upload failed for ${patientLabel} (${aptLabel}): ${pdfErr?.message ?? String(pdfErr)}`;
|
||||
}
|
||||
} else {
|
||||
// no pdf; still mark processed true (status updated)
|
||||
resultItem.processed = true;
|
||||
resultItem.pdfFileId = null;
|
||||
}
|
||||
|
||||
results.push(resultItem);
|
||||
} catch (err: any) {
|
||||
resultItem.error = `Unexpected error for appointment#${apt.id}: ${err?.message ?? String(err)}`;
|
||||
results.push(resultItem);
|
||||
|
||||
console.error(
|
||||
"[batch eligibility] unexpected error for appointment",
|
||||
apt.id,
|
||||
err
|
||||
);
|
||||
} finally {
|
||||
// Per-appointment cleanup: always try to remove selenium temp files for this appointment
|
||||
try {
|
||||
if (
|
||||
seleniumResult &&
|
||||
(seleniumResult.pdf_path || seleniumResult.ss_path)
|
||||
) {
|
||||
// prefer pdf_path, fallback to ss_path
|
||||
const candidatePath =
|
||||
seleniumResult.pdf_path ?? seleniumResult.ss_path;
|
||||
try {
|
||||
await emptyFolderContainingFile(candidatePath);
|
||||
} catch (cleanupErr: any) {
|
||||
console.warn(
|
||||
`[batch cleanup] failed to clean ${candidatePath} for appointment ${apt.id}`,
|
||||
cleanupErr
|
||||
);
|
||||
// remember path for final cleanup attempt
|
||||
remainingCleanupPaths.add(candidatePath);
|
||||
}
|
||||
}
|
||||
} catch (cleanupOuterErr: any) {
|
||||
console.warn(
|
||||
"[batch cleanup] unexpected error during per-appointment cleanup",
|
||||
cleanupOuterErr
|
||||
);
|
||||
// don't throw — we want to continue processing next appointments
|
||||
}
|
||||
} // end try/catch/finally per appointment
|
||||
} // end for appointments
|
||||
|
||||
// return summary
|
||||
return res.json({ date, count: results.length, results });
|
||||
} catch (err: any) {
|
||||
console.error("[check-all-eligibilities] error", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: err?.message ?? "Internal server error" });
|
||||
} finally {
|
||||
// Final cleanup attempt for any remaining paths we couldn't delete earlier
|
||||
try {
|
||||
if (remainingCleanupPaths.size > 0) {
|
||||
for (const p of remainingCleanupPaths) {
|
||||
try {
|
||||
await emptyFolderContainingFile(p);
|
||||
} catch (finalCleanupErr: any) {
|
||||
console.error(`[final cleanup] failed for ${p}`, finalCleanupErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (outerFinalErr: any) {
|
||||
console.error(
|
||||
"[check-all-eligibilities final cleanup] unexpected error",
|
||||
outerFinalErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
699
apps/Backend/src/routes/insuranceStatusDDMA.ts
Executable file
699
apps/Backend/src/routes/insuranceStatusDDMA.ts
Executable file
@@ -0,0 +1,699 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
forwardToSeleniumDdmaEligibilityAgent,
|
||||
forwardOtpToSeleniumDdmaAgent,
|
||||
getSeleniumDdmaSessionStatus,
|
||||
} from "../services/seleniumDdmaInsuranceEligibilityClient";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import PDFDocument from "pdfkit";
|
||||
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||
import {
|
||||
InsertPatient,
|
||||
insertPatientSchema,
|
||||
} from "../../../../packages/db/types/patient-types";
|
||||
import { io } from "../socket";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Job context stored in memory by sessionId */
|
||||
interface DdmaJobContext {
|
||||
userId: number;
|
||||
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
const ddmaJobs: Record<string, DdmaJobContext> = {};
|
||||
|
||||
/** Utility: naive name splitter */
|
||||
function splitName(fullName?: string | null) {
|
||||
if (!fullName) return { firstName: "", lastName: "" };
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
const firstName = parts.shift() ?? "";
|
||||
const lastName = parts.join(" ") ?? "";
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({ autoFirstPage: false });
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
doc.on("data", (chunk: any) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", (err: any) => reject(err));
|
||||
|
||||
const A4_WIDTH = 595.28; // points
|
||||
const A4_HEIGHT = 841.89; // points
|
||||
|
||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||
|
||||
doc.image(imagePath, 0, 0, {
|
||||
fit: [A4_WIDTH, A4_HEIGHT],
|
||||
align: "center",
|
||||
valign: "center",
|
||||
});
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure patient exists for given insuranceId.
|
||||
*/
|
||||
async function createOrUpdatePatientByInsuranceId(options: {
|
||||
insuranceId: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
dob?: string | Date | null;
|
||||
userId: number;
|
||||
}) {
|
||||
const { insuranceId, firstName, lastName, dob, userId } = options;
|
||||
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||
|
||||
const incomingFirst = (firstName || "").trim();
|
||||
const incomingLast = (lastName || "").trim();
|
||||
|
||||
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
|
||||
if (patient && patient.id) {
|
||||
const updates: any = {};
|
||||
if (
|
||||
incomingFirst &&
|
||||
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||
) {
|
||||
updates.firstName = incomingFirst;
|
||||
}
|
||||
if (
|
||||
incomingLast &&
|
||||
String(patient.lastName ?? "").trim() !== incomingLast
|
||||
) {
|
||||
updates.lastName = incomingLast;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const createPayload: any = {
|
||||
firstName: incomingFirst,
|
||||
lastName: incomingLast,
|
||||
dateOfBirth: dob,
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
insuranceId,
|
||||
};
|
||||
let patientData: InsertPatient;
|
||||
try {
|
||||
patientData = insertPatientSchema.parse(createPayload);
|
||||
} catch (err) {
|
||||
const safePayload = { ...createPayload };
|
||||
delete (safePayload as any).dateOfBirth;
|
||||
patientData = insertPatientSchema.parse(safePayload);
|
||||
}
|
||||
await storage.createPatient(patientData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
|
||||
* and return the final API response shape.
|
||||
*/
|
||||
async function handleDdmaCompletedJob(
|
||||
sessionId: string,
|
||||
job: DdmaJobContext,
|
||||
seleniumResult: any
|
||||
) {
|
||||
let createdPdfFileId: number | null = null;
|
||||
const outputResult: any = {};
|
||||
|
||||
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||
try {
|
||||
// 1) ensuring memberid.
|
||||
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||
if (!insuranceId) {
|
||||
throw new Error("Missing memberId for ddma job");
|
||||
}
|
||||
|
||||
// 2) Create or update patient (with name from selenium result if available)
|
||||
const patientNameFromResult =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: insuranceEligibilityData.dateOfBirth,
|
||||
userId: job.userId,
|
||||
});
|
||||
|
||||
// 3) Update patient status + PDF upload
|
||||
const patient = await storage.getPatientByInsuranceId(
|
||||
insuranceEligibilityData.memberId
|
||||
);
|
||||
if (!patient?.id) {
|
||||
outputResult.patientUpdateStatus =
|
||||
"Patient not found; no update performed";
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: "none",
|
||||
pdfFileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// update patient status.
|
||||
const newStatus =
|
||||
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||
await storage.updatePatient(patient.id, { status: newStatus });
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// convert screenshot -> pdf if available
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let generatedPdfPath: string | null = null;
|
||||
|
||||
if (
|
||||
seleniumResult &&
|
||||
seleniumResult.ss_path &&
|
||||
typeof seleniumResult.ss_path === "string" &&
|
||||
(seleniumResult.ss_path.endsWith(".png") ||
|
||||
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||
seleniumResult.ss_path.endsWith(".jpeg"))
|
||||
) {
|
||||
try {
|
||||
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
||||
throw new Error(
|
||||
`Screenshot file not found: ${seleniumResult.ss_path}`
|
||||
);
|
||||
}
|
||||
|
||||
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||
|
||||
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||
generatedPdfPath = path.join(
|
||||
path.dirname(seleniumResult.ss_path),
|
||||
pdfFileName
|
||||
);
|
||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||
|
||||
// ensure cleanup uses this
|
||||
seleniumResult.pdf_path = generatedPdfPath;
|
||||
} catch (err: any) {
|
||||
console.error("Failed to convert screenshot to PDF:", err);
|
||||
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||
}
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
||||
}
|
||||
|
||||
if (pdfBuffer && generatedPdfPath) {
|
||||
const groupTitle = "Eligibility Status";
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
patient.id,
|
||||
groupTitleKey
|
||||
);
|
||||
if (!group) {
|
||||
group = await storage.createPdfGroup(
|
||||
patient.id,
|
||||
groupTitle,
|
||||
groupTitleKey
|
||||
);
|
||||
}
|
||||
if (!group?.id) {
|
||||
throw new Error("PDF group creation failed: missing group ID");
|
||||
}
|
||||
|
||||
const created = await storage.createPdfFile(
|
||||
group.id,
|
||||
path.basename(generatedPdfPath),
|
||||
pdfBuffer
|
||||
);
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
} else {
|
||||
outputResult.pdfUploadStatus =
|
||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||
}
|
||||
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||
pdfFileId: createdPdfFileId,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||
pdfUploadStatus:
|
||||
outputResult.pdfUploadStatus ??
|
||||
`Failed to process DDMA job: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
error: err?.message ?? String(err),
|
||||
};
|
||||
} finally {
|
||||
// ALWAYS attempt cleanup of temp files
|
||||
try {
|
||||
if (seleniumResult && seleniumResult.pdf_path) {
|
||||
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||
} else if (seleniumResult && seleniumResult.ss_path) {
|
||||
await emptyFolderContainingFile(seleniumResult.ss_path);
|
||||
} else {
|
||||
console.log(
|
||||
`[ddma-eligibility] no pdf_path or ss_path available to cleanup`
|
||||
);
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
console.error(
|
||||
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
|
||||
cleanupErr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- top of file, alongside ddmaJobs ---
|
||||
let currentFinalSessionId: string | null = null;
|
||||
let currentFinalResult: any = null;
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId) {
|
||||
log("socket", "no socketId for emit", { event });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const socket = io?.sockets.sockets.get(socketId);
|
||||
if (!socket) {
|
||||
log("socket", "socket not found (maybe disconnected)", {
|
||||
socketId,
|
||||
event,
|
||||
});
|
||||
return;
|
||||
}
|
||||
socket.emit(event, payload);
|
||||
log("socket", "emitted", { socketId, event });
|
||||
} catch (err: any) {
|
||||
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls Python agent for session status and emits socket events:
|
||||
* - 'selenium:otp_required' when waiting_for_otp
|
||||
* - 'selenium:session_update' when completed/error
|
||||
* - rabsolute timeout + transient error handling.
|
||||
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
|
||||
*/
|
||||
async function pollAgentSessionAndProcess(
|
||||
sessionId: string,
|
||||
socketId?: string,
|
||||
pollTimeoutMs = 2 * 60 * 1000
|
||||
) {
|
||||
const maxAttempts = 300;
|
||||
const baseDelayMs = 1000;
|
||||
const maxTransientErrors = 12;
|
||||
|
||||
// NEW: give up if same non-terminal status repeats this many times
|
||||
const noProgressLimit = 100;
|
||||
|
||||
const job = ddmaJobs[sessionId];
|
||||
let transientErrorCount = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// absolute deadline check
|
||||
if (Date.now() > deadline) {
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
|
||||
});
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
"poller",
|
||||
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
|
||||
);
|
||||
|
||||
try {
|
||||
const st = await getSeleniumDdmaSessionStatus(sessionId);
|
||||
const status = st?.status ?? null;
|
||||
log("poller", "got status", {
|
||||
sessionId,
|
||||
status,
|
||||
message: st?.message,
|
||||
resultKeys: st?.result ? Object.keys(st.result) : null,
|
||||
});
|
||||
|
||||
// reset transient errors on success
|
||||
transientErrorCount = 0;
|
||||
|
||||
// if status unchanged and non-terminal, increment no-progress counter
|
||||
const isTerminalLike =
|
||||
status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminalLike) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
// if no progress for too many consecutive polls -> abort
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
|
||||
});
|
||||
emitSafe(socketId, "selenium:session_error", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: "No progress from selenium agent",
|
||||
});
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// always emit debug to client if socket exists
|
||||
emitSafe(socketId, "selenium:debug", {
|
||||
session_id: sessionId,
|
||||
attempt,
|
||||
status,
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If agent is waiting for OTP, inform client but keep polling (do not return)
|
||||
if (status === "waiting_for_otp") {
|
||||
emitSafe(socketId, "selenium:otp_required", {
|
||||
session_id: sessionId,
|
||||
message: "OTP required. Please enter the OTP.",
|
||||
});
|
||||
// do not return — keep polling (allows same poller to pick up completion)
|
||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Completed path
|
||||
if (status === "completed") {
|
||||
log("poller", "agent completed; processing result", {
|
||||
sessionId,
|
||||
resultKeys: st.result ? Object.keys(st.result) : null,
|
||||
});
|
||||
|
||||
// Persist raw result so frontend can fetch if socket disconnects
|
||||
currentFinalSessionId = sessionId;
|
||||
currentFinalResult = {
|
||||
rawSelenium: st.result,
|
||||
processedAt: null,
|
||||
final: null,
|
||||
};
|
||||
|
||||
let finalResult: any = null;
|
||||
if (job && st.result) {
|
||||
try {
|
||||
finalResult = await handleDdmaCompletedJob(
|
||||
sessionId,
|
||||
job,
|
||||
st.result
|
||||
);
|
||||
currentFinalResult.final = finalResult;
|
||||
currentFinalResult.processedAt = Date.now();
|
||||
} catch (err: any) {
|
||||
currentFinalResult.final = {
|
||||
error: "processing_failed",
|
||||
detail: err?.message ?? String(err),
|
||||
};
|
||||
currentFinalResult.processedAt = Date.now();
|
||||
log("poller", "handleDdmaCompletedJob failed", {
|
||||
sessionId,
|
||||
err: err?.message ?? err,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
currentFinalResult[sessionId].final = {
|
||||
error: "no_job_or_no_result",
|
||||
};
|
||||
currentFinalResult[sessionId].processedAt = Date.now();
|
||||
}
|
||||
|
||||
// Emit final update (if socket present)
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "completed",
|
||||
rawSelenium: st.result,
|
||||
final: currentFinalResult.final,
|
||||
});
|
||||
|
||||
// cleanup job context
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal error / not_found
|
||||
if (status === "error" || status === "not_found") {
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status,
|
||||
message: st?.message || "Selenium session error",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const axiosStatus =
|
||||
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
|
||||
const errCode = err?.code ?? err?.errno;
|
||||
const errMsg = err?.message ?? String(err);
|
||||
const errData = err?.response?.data ?? null;
|
||||
|
||||
// If agent explicitly returned 404 -> terminal (session gone)
|
||||
if (
|
||||
axiosStatus === 404 ||
|
||||
(typeof errMsg === "string" && errMsg.includes("not_found"))
|
||||
) {
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
|
||||
);
|
||||
|
||||
// Emit not_found to client
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status: "not_found",
|
||||
message:
|
||||
errData?.detail || "Selenium session not found (agent cleaned up).",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
|
||||
// Remove job context and stop polling
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
// Detailed transient error logging
|
||||
transientErrorCount++;
|
||||
if (transientErrorCount > maxTransientErrors) {
|
||||
const emitPayload = {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message:
|
||||
"Repeated network errors while polling selenium agent; giving up.",
|
||||
};
|
||||
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||
delete ddmaJobs[sessionId];
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(
|
||||
30_000,
|
||||
baseDelayMs * Math.pow(2, transientErrorCount - 1)
|
||||
);
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
|
||||
);
|
||||
console.warn(
|
||||
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, backoffMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// normal poll interval
|
||||
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||
}
|
||||
|
||||
// overall timeout fallback
|
||||
emitSafe(socketId, "selenium:session_update", {
|
||||
session_id: sessionId,
|
||||
status: "error",
|
||||
message: "Polling timeout while waiting for selenium session",
|
||||
});
|
||||
delete ddmaJobs[sessionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ddma-eligibility
|
||||
* Starts DDMA eligibility Selenium job.
|
||||
* Expects:
|
||||
* - req.body.data: stringified JSON like your existing /eligibility-check
|
||||
* - req.body.socketId: socket.io client id
|
||||
*/
|
||||
router.post(
|
||||
"/ddma-eligibility",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||
}
|
||||
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData =
|
||||
typeof req.body.data === "string"
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body.data;
|
||||
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
rawData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
massddmaUsername: credentials.username,
|
||||
massddmaPassword: credentials.password,
|
||||
};
|
||||
|
||||
const socketId: string | undefined = req.body.socketId;
|
||||
|
||||
const agentResp =
|
||||
await forwardToSeleniumDdmaEligibilityAgent(enrichedData);
|
||||
|
||||
if (
|
||||
!agentResp ||
|
||||
agentResp.status !== "started" ||
|
||||
!agentResp.session_id
|
||||
) {
|
||||
return res.status(502).json({
|
||||
error: "Selenium agent did not return a started session",
|
||||
detail: agentResp,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
|
||||
// Save job context
|
||||
ddmaJobs[sessionId] = {
|
||||
userId: req.user.id,
|
||||
insuranceEligibilityData: enrichedData,
|
||||
socketId,
|
||||
};
|
||||
|
||||
// start polling in background to notify client via socket and process job
|
||||
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
|
||||
console.warn("pollAgentSessionAndProcess failed", e)
|
||||
);
|
||||
|
||||
// reply immediately with started status
|
||||
return res.json({ status: "started", session_id: sessionId });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to start ddma selenium agent",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /selenium/submit-otp
|
||||
* Body: { session_id, otp, socketId? }
|
||||
* Forwards OTP to Python agent and optionally notifies client socket.
|
||||
*/
|
||||
router.post(
|
||||
"/selenium/submit-otp",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const { session_id: sessionId, otp, socketId } = req.body;
|
||||
if (!sessionId || !otp) {
|
||||
return res.status(400).json({ error: "session_id and otp are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
|
||||
|
||||
// emit OTP accepted (if socket present)
|
||||
emitSafe(socketId, "selenium:otp_submitted", {
|
||||
session_id: sessionId,
|
||||
result: r,
|
||||
});
|
||||
|
||||
return res.json(r);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"Failed to forward OTP:",
|
||||
err?.response?.data || err?.message || err
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to forward otp to selenium agent",
|
||||
detail: err?.message || err,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /selenium/session/:sid/final
|
||||
router.get(
|
||||
"/selenium/session/:sid/final",
|
||||
async (req: Request, res: Response) => {
|
||||
const sid = req.params.sid;
|
||||
if (!sid) return res.status(400).json({ error: "session id required" });
|
||||
|
||||
// Only the current in-memory result is available
|
||||
if (currentFinalSessionId !== sid || !currentFinalResult) {
|
||||
return res.status(404).json({ error: "final result not found" });
|
||||
}
|
||||
|
||||
return res.json(currentFinalResult);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
69
apps/Backend/src/routes/notifications.ts
Executable file
69
apps/Backend/src/routes/notifications.ts
Executable file
@@ -0,0 +1,69 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const notifications = await storage.getNotifications(userId, 20, 0);
|
||||
res.json(notifications);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch notifications:", err);
|
||||
res.status(500).json({ message: "Failed to fetch notifications" });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark one notification as read
|
||||
router.post("/:id/read", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const success = await storage.markNotificationRead(
|
||||
userId,
|
||||
Number(req.params.id)
|
||||
);
|
||||
|
||||
if (!success)
|
||||
return res.status(404).json({ message: "Notification not found" });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("Failed to mark notification as read:", err);
|
||||
res.status(500).json({ message: "Failed to mark notification as read" });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all notifications as read
|
||||
router.post("/read-all", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const count = await storage.markAllNotificationsRead(userId);
|
||||
res.json({ success: true, updatedCount: count });
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all notifications read:", err);
|
||||
res.status(500).json({ message: "Failed to mark all notifications read" });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete(
|
||||
"/delete-all",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const deletedCount = await storage.deleteAllNotifications(userId);
|
||||
res.json({ success: true, deletedCount });
|
||||
} catch (err) {
|
||||
console.error("Failed to delete notifications:", err);
|
||||
res.status(500).json({ message: "Failed to delete notifications" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
99
apps/Backend/src/routes/npiProviders.ts
Executable file
99
apps/Backend/src/routes/npiProviders.ts
Executable file
@@ -0,0 +1,99 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { storage } from "../storage";
|
||||
import { insertNpiProviderSchema } from "@repo/db/types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const providers = await storage.getNpiProvidersByUser(req.user.id);
|
||||
res.status(200).json(providers);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to fetch NPI providers",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const parsed = insertNpiProviderSchema.safeParse({
|
||||
...req.body,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const flat = parsed.error.flatten();
|
||||
const firstError =
|
||||
Object.values(flat.fieldErrors)[0]?.[0] || "Invalid input";
|
||||
|
||||
return res.status(400).json({
|
||||
message: firstError,
|
||||
details: flat.fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = await storage.createNpiProvider(parsed.data);
|
||||
res.status(201).json(provider);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
return res.status(400).json({
|
||||
message: "This NPI already exists for the user",
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
error: "Failed to create NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||
|
||||
const provider = await storage.updateNpiProvider(id, req.body);
|
||||
res.status(200).json(provider);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to update NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?.id) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||
|
||||
const ok = await storage.deleteNpiProvider(req.user.id, id);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ message: "NPI provider not found" });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: "Failed to delete NPI provider",
|
||||
details: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
258
apps/Backend/src/routes/patient-documents.ts
Executable file
258
apps/Backend/src/routes/patient-documents.ts
Executable file
@@ -0,0 +1,258 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept common document and image formats
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only PDF, images, and documents are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Validation schemas
|
||||
const uploadDocumentSchema = z.object({
|
||||
patientId: z.string().transform((val) => parseInt(val, 10)),
|
||||
});
|
||||
|
||||
const getDocumentsSchema = z.object({
|
||||
patientId: z.string().transform((val) => parseInt(val, 10)),
|
||||
limit: z.string().optional().transform((val) => val ? parseInt(val, 10) : undefined),
|
||||
offset: z.string().optional().transform((val) => val ? parseInt(val, 10) : undefined),
|
||||
});
|
||||
|
||||
const deleteDocumentSchema = z.object({
|
||||
id: z.string().transform((val) => parseInt(val, 10)),
|
||||
});
|
||||
|
||||
// POST /api/patient-documents/upload
|
||||
// Upload a document for a specific patient
|
||||
router.post("/upload", upload.single("file"), async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { patientId } = uploadDocumentSchema.parse(req.body);
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
const document = await storage.createPatientDocument(
|
||||
patientId,
|
||||
file.originalname,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
file.buffer
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
document: {
|
||||
...document,
|
||||
fileSize: Number(document.fileSize), // Convert BigInt to Number for JSON serialization
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading document:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid request data", details: error.errors });
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('Invalid file type')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/patient-documents/patient/:patientId
|
||||
// Get all documents for a specific patient
|
||||
router.get("/patient/:patientId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { patientId, limit, offset } = getDocumentsSchema.parse({
|
||||
patientId: req.params.patientId,
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
|
||||
if (limit !== undefined && offset !== undefined) {
|
||||
// Paginated response
|
||||
const result = await storage.getDocumentsByPatientIdPaginated(patientId, limit, offset);
|
||||
res.json({
|
||||
success: true,
|
||||
documents: result.documents.map(doc => ({
|
||||
...doc,
|
||||
fileSize: Number(doc.fileSize), // Convert BigInt to Number
|
||||
})),
|
||||
total: result.total,
|
||||
});
|
||||
} else {
|
||||
// Non-paginated response
|
||||
const documents = await storage.getDocumentsByPatientId(patientId);
|
||||
res.json({
|
||||
success: true,
|
||||
documents: documents.map(doc => ({
|
||||
...doc,
|
||||
fileSize: Number(doc.fileSize), // Convert BigInt to Number
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching documents:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid patient ID", details: error.errors });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/patient-documents/:id/download
|
||||
// Download a specific document
|
||||
router.get("/:id/download", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
|
||||
|
||||
const result = await storage.getDocumentFile(id);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: "Document not found" });
|
||||
}
|
||||
|
||||
const { buffer, document } = result;
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader("Content-Type", document.mimeType);
|
||||
res.setHeader("Content-Length", document.fileSize.toString());
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(document.originalName)}"`
|
||||
);
|
||||
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error("Error downloading document:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/patient-documents/:id/view
|
||||
// View a specific document (inline display)
|
||||
router.get("/:id/view", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
|
||||
|
||||
const result = await storage.getDocumentFile(id);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: "Document not found" });
|
||||
}
|
||||
|
||||
const { buffer, document } = result;
|
||||
|
||||
// Set appropriate headers for inline viewing
|
||||
res.setHeader("Content-Type", document.mimeType);
|
||||
res.setHeader("Content-Length", document.fileSize.toString());
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${encodeURIComponent(document.originalName)}"`
|
||||
);
|
||||
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error("Error viewing document:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/patient-documents/:id
|
||||
// Delete a specific document
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { id } = deleteDocumentSchema.parse({ id: req.params.id });
|
||||
|
||||
const success = await storage.deleteDocument(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "Document not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "Document deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting document:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid document ID", details: error.errors });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/patient-documents/scan
|
||||
// Simulate document scanning (placeholder for actual scanner integration)
|
||||
router.post("/scan", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { patientId } = uploadDocumentSchema.parse(req.body);
|
||||
|
||||
// This is a placeholder for actual scanner integration
|
||||
// In a real implementation, you would:
|
||||
// 1. Interface with scanner hardware/software
|
||||
// 2. Capture the scanned image
|
||||
// 3. Process and save the image
|
||||
// 4. Return the document info
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Scanner interface ready. Please integrate with your scanner hardware/software.",
|
||||
patientId,
|
||||
note: "This endpoint requires integration with scanner hardware/software SDK."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error scanning document:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: "Invalid request data", details: error.errors });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
23
apps/Backend/src/routes/patientDataExtraction.ts
Executable file
23
apps/Backend/src/routes/patientDataExtraction.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
const router = Router();
|
||||
import multer from "multer";
|
||||
import forwardToPatientDataExtractorService from "../services/patientDataExtractorService";
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
router.post("/patientdataextract", upload.single("pdf"), async (req: Request, res: Response): Promise<any>=> {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No PDF file uploaded." });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await forwardToPatientDataExtractorService(req.file);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Extraction failed" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
376
apps/Backend/src/routes/patients.ts
Executable file
376
apps/Backend/src/routes/patients.ts
Executable file
@@ -0,0 +1,376 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { insertPatientSchema, updatePatientSchema } from "@repo/db/types";
|
||||
import { normalizeInsuranceId } from "../utils/helpers";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Patient Routes
|
||||
// Get all patients for the logged-in user
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const patients = await storage.getPatientsByUserId(req.user!.id);
|
||||
res.json(patients);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve patients" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent patients (paginated)
|
||||
router.get("/recent", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const [patients, totalCount] = await Promise.all([
|
||||
storage.getRecentPatients(limit, offset),
|
||||
storage.getTotalPatientCount(),
|
||||
]);
|
||||
|
||||
res.json({ patients, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve recent patients:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve recent patients" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/search", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
phone,
|
||||
insuranceId,
|
||||
gender,
|
||||
dob,
|
||||
term,
|
||||
limit = "10",
|
||||
offset = "0",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const filters: any = {};
|
||||
|
||||
if (term) {
|
||||
filters.OR = [
|
||||
{ firstName: { contains: term, mode: "insensitive" } },
|
||||
{ lastName: { contains: term, mode: "insensitive" } },
|
||||
{ phone: { contains: term, mode: "insensitive" } },
|
||||
{ insuranceId: { contains: term, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (name) {
|
||||
filters.OR = [
|
||||
{ firstName: { contains: name, mode: "insensitive" } },
|
||||
{ lastName: { contains: name, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
filters.phone = { contains: phone, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (insuranceId) {
|
||||
filters.insuranceId = { contains: insuranceId, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
filters.gender = gender;
|
||||
}
|
||||
|
||||
if (dob) {
|
||||
const parsed = new Date(dob);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid date format for DOB. Use format: YYYY-MM-DD",
|
||||
});
|
||||
}
|
||||
// Match exact dateOfBirth (optional: adjust for timezone)
|
||||
filters.dateOfBirth = parsed;
|
||||
}
|
||||
|
||||
const [patients, totalCount] = await Promise.all([
|
||||
storage.searchPatients({
|
||||
filters,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
}),
|
||||
storage.countPatients(filters),
|
||||
]);
|
||||
|
||||
return res.json({ patients, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
return res.status(500).json({ message: "Failed to search patients" });
|
||||
}
|
||||
});
|
||||
|
||||
// get patient by insurance id
|
||||
router.get(
|
||||
"/by-insurance-id",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
const insuranceId = req.query.insuranceId?.toString();
|
||||
|
||||
if (!insuranceId) {
|
||||
return res.status(400).json({ error: "Missing insuranceId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
|
||||
if (patient) {
|
||||
return res.status(200).json(patient);
|
||||
} else {
|
||||
return res.status(200).json(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to lookup patient:", err);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/patients/:id/financials?limit=50&offset=0
|
||||
router.get(
|
||||
"/:id/financials",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
if (!patientIdParam)
|
||||
return res.status(400).json({ message: "Patient ID required" });
|
||||
|
||||
const patientId = parseInt(patientIdParam, 10);
|
||||
if (isNaN(patientId))
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
|
||||
const limit = Math.min(1000, Number(req.query.limit ?? 50)); // cap maximums
|
||||
const offset = Math.max(0, Number(req.query.offset ?? 0));
|
||||
|
||||
const { rows, totalCount } = await storage.getPatientFinancialRows(
|
||||
patientId,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
return res.json({ rows, totalCount, limit, offset });
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch financial rows:", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch financial rows" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get a single patient by ID
|
||||
router.get(
|
||||
"/:id",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
|
||||
// Ensure that patientIdParam exists and is a valid number
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Patient ID is required" });
|
||||
}
|
||||
|
||||
const patientId = parseInt(patientIdParam);
|
||||
|
||||
const patient = await storage.getPatient(patientId);
|
||||
|
||||
if (!patient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
res.json(patient);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve patient" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new patient
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const body: any = { ...req.body, userId: req.user!.id };
|
||||
|
||||
// Normalize insuranceId early and return clear error if invalid
|
||||
try {
|
||||
const normalized = normalizeInsuranceId(body.insuranceId);
|
||||
body.insuranceId = normalized;
|
||||
} catch (err: any) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid insuranceId",
|
||||
details: err?.message ?? "Invalid insuranceId format",
|
||||
});
|
||||
}
|
||||
// Validate request body
|
||||
const patientData = insertPatientSchema.parse({
|
||||
...req.body,
|
||||
userId: req.user!.id,
|
||||
});
|
||||
|
||||
// Check for duplicate insuranceId if it's provided
|
||||
if (patientData.insuranceId) {
|
||||
const existingPatient = await storage.getPatientByInsuranceId(
|
||||
patientData.insuranceId as string
|
||||
);
|
||||
|
||||
if (existingPatient) {
|
||||
return res.status(409).json({
|
||||
message: "A patient with this insurance ID already exists.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const patient = await storage.createPatient(patientData);
|
||||
|
||||
res.status(201).json(patient);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: "Failed to create patient" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update an existing patient
|
||||
router.put(
|
||||
"/:id",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
|
||||
// Normalize incoming insuranceId (if present)
|
||||
try {
|
||||
if (req.body.insuranceId !== undefined) {
|
||||
req.body.insuranceId = normalizeInsuranceId(req.body.insuranceId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid insuranceId",
|
||||
details: err?.message ?? "Invalid insuranceId format",
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure that patientIdParam exists and is a valid number
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Patient ID is required" });
|
||||
}
|
||||
|
||||
const patientId = parseInt(patientIdParam);
|
||||
|
||||
// Check if patient exists and belongs to user
|
||||
const existingPatient = await storage.getPatient(patientId);
|
||||
if (!existingPatient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const patientData = updatePatientSchema.parse(req.body);
|
||||
|
||||
// If updating insuranceId, check for uniqueness (excluding self)
|
||||
if (
|
||||
patientData.insuranceId &&
|
||||
patientData.insuranceId !== existingPatient.insuranceId
|
||||
) {
|
||||
const duplicatePatient = await storage.getPatientByInsuranceId(
|
||||
patientData.insuranceId as string
|
||||
);
|
||||
if (duplicatePatient && duplicatePatient.id !== patientId) {
|
||||
return res.status(409).json({
|
||||
message: "Another patient with this insurance ID already exists.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update patient
|
||||
const updatedPatient = await storage.updatePatient(
|
||||
patientId,
|
||||
patientData
|
||||
);
|
||||
res.json(updatedPatient);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update patient" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a patient
|
||||
router.delete(
|
||||
"/:id",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
|
||||
// Ensure that patientIdParam exists and is a valid number
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Patient ID is required" });
|
||||
}
|
||||
|
||||
const patientId = parseInt(patientIdParam);
|
||||
|
||||
// Check if patient exists and belongs to user
|
||||
const existingPatient = await storage.getPatient(patientId);
|
||||
if (!existingPatient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
|
||||
if (existingPatient.userId !== req.user!.id) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Patient belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete patient
|
||||
await storage.deletePatient(patientId);
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
console.error("Delete patient error:", error);
|
||||
res.status(500).json({ message: "Failed to delete patient" });
|
||||
}
|
||||
}
|
||||
);
|
||||
// Get appointments for a specific patient
|
||||
router.get(
|
||||
"/:patientId/appointments",
|
||||
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.id;
|
||||
|
||||
// Ensure that patientIdParam exists and is a valid number
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Patient ID is required" });
|
||||
}
|
||||
|
||||
const patientId = parseInt(patientIdParam);
|
||||
|
||||
// Check if patient exists and belongs to user
|
||||
const patient = await storage.getPatient(patientId);
|
||||
if (!patient) {
|
||||
return res.status(404).json({ message: "Patient not found" });
|
||||
}
|
||||
|
||||
const appointments = await storage.getAppointmentsByPatientId(patientId);
|
||||
res.json(appointments);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: "Failed to retrieve appointments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
50
apps/Backend/src/routes/paymentOcrExtraction.ts
Executable file
50
apps/Backend/src/routes/paymentOcrExtraction.ts
Executable file
@@ -0,0 +1,50 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { forwardToPaymentOCRService } from "../services/paymentOCRService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// keep files in memory; FastAPI accepts them as multipart bytes
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// POST /payment-ocr/extract (field name: "files")
|
||||
router.post(
|
||||
"/extract",
|
||||
upload.array("files"), // allow multiple images
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const files = req.files as Express.Multer.File[] | undefined;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No image files uploaded. Use field name 'files'." });
|
||||
}
|
||||
|
||||
// (optional) basic client-side MIME guard
|
||||
const allowed = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/jpg",
|
||||
]);
|
||||
const bad = files.filter((f) => !allowed.has(f.mimetype.toLowerCase()));
|
||||
if (bad.length) {
|
||||
return res.status(415).json({
|
||||
error: `Unsupported file types: ${bad
|
||||
.map((b) => b.originalname)
|
||||
.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await forwardToPaymentOCRService(files);
|
||||
return res.json({ rows });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: "Payment OCR extraction failed" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
213
apps/Backend/src/routes/payments-reports.ts
Executable file
213
apps/Backend/src/routes/payments-reports.ts
Executable file
@@ -0,0 +1,213 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/summary
|
||||
* optional query: from=YYYY-MM-DD&to=YYYY-MM-DD (ISO date strings)
|
||||
*/
|
||||
router.get("/summary", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : undefined;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||
|
||||
if (req.query.from && isNaN(from?.getTime() ?? NaN))
|
||||
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||
if (req.query.to && isNaN(to?.getTime() ?? NaN))
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
|
||||
const summary = await storage.getSummary(from, to);
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/summary error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({ message: "Failed to fetch dashboard summary" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/patient-balances
|
||||
* query:
|
||||
* - limit (default 25)
|
||||
* - cursor (optional base64 cursor token)
|
||||
* - from / to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||
*/
|
||||
router.get(
|
||||
"/patients-with-balances",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
|
||||
);
|
||||
|
||||
const cursor =
|
||||
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
||||
|
||||
const from = req.query.from
|
||||
? new Date(String(req.query.from))
|
||||
: undefined;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||
|
||||
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||
}
|
||||
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
const data = await storage.getPatientsWithBalances(
|
||||
limit,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
// returns { balances, totalCount, nextCursor, hasMore }
|
||||
res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/patient-balances error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({ message: "Failed to fetch patient balances" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/by-doctor/balances
|
||||
* Query params:
|
||||
* - staffId (required)
|
||||
* - limit (optional, default 25)
|
||||
* - cursor (optional)
|
||||
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||
*
|
||||
* Response: { balances, totalCount, nextCursor, hasMore }
|
||||
*/
|
||||
router.get(
|
||||
"/by-doctor/balances",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const staffIdRaw = req.query.staffId;
|
||||
if (!staffIdRaw) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Missing required 'staffId' query parameter" });
|
||||
}
|
||||
const staffId = Number(staffIdRaw);
|
||||
if (!Number.isFinite(staffId) || staffId <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Invalid 'staffId' query parameter" });
|
||||
}
|
||||
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(200, parseInt(String(req.query.limit || "25"), 10))
|
||||
);
|
||||
|
||||
const cursor =
|
||||
typeof req.query.cursor === "string" ? String(req.query.cursor) : null;
|
||||
|
||||
const from = req.query.from
|
||||
? new Date(String(req.query.from))
|
||||
: undefined;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||
|
||||
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||
}
|
||||
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
// use the new storage method that returns only the paged balances
|
||||
const balancesResult = await storage.getPatientsBalancesByDoctor(
|
||||
staffId,
|
||||
limit,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
res.json({
|
||||
balances: balancesResult?.balances ?? [],
|
||||
totalCount: Number(balancesResult?.totalCount ?? 0),
|
||||
nextCursor: balancesResult?.nextCursor ?? null,
|
||||
hasMore: Boolean(balancesResult?.hasMore ?? false),
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/by-doctor/balances error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({
|
||||
message: "Failed to fetch doctor balances",
|
||||
detail: err?.message ?? String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/payments-reports/by-doctor/summary
|
||||
* Query params:
|
||||
* - staffId (required)
|
||||
* - from/to (optional ISO date strings) - filter payments by createdAt in the range (inclusive)
|
||||
*
|
||||
* Response: { totalPatients, totalOutstanding, totalCollected, patientsWithBalance }
|
||||
*/
|
||||
router.get(
|
||||
"/by-doctor/summary",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const staffIdRaw = req.query.staffId;
|
||||
if (!staffIdRaw) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Missing required 'staffId' query parameter" });
|
||||
}
|
||||
const staffId = Number(staffIdRaw);
|
||||
if (!Number.isFinite(staffId) || staffId <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Invalid 'staffId' query parameter" });
|
||||
}
|
||||
|
||||
const from = req.query.from
|
||||
? new Date(String(req.query.from))
|
||||
: undefined;
|
||||
const to = req.query.to ? new Date(String(req.query.to)) : undefined;
|
||||
|
||||
if (req.query.from && isNaN(from?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'from' date" });
|
||||
}
|
||||
if (req.query.to && isNaN(to?.getTime() ?? NaN)) {
|
||||
return res.status(400).json({ message: "Invalid 'to' date" });
|
||||
}
|
||||
|
||||
// use the new storage method that returns only the summary for the staff
|
||||
const summary = await storage.getSummaryByDoctor(staffId, from, to);
|
||||
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"GET /api/payments-reports/by-doctor/summary error:",
|
||||
err?.message ?? err,
|
||||
err?.stack
|
||||
);
|
||||
res.status(500).json({
|
||||
message: "Failed to fetch doctor summary",
|
||||
detail: err?.message ?? String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
459
apps/Backend/src/routes/payments.ts
Executable file
459
apps/Backend/src/routes/payments.ts
Executable file
@@ -0,0 +1,459 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
insertPaymentSchema,
|
||||
NewTransactionPayload,
|
||||
newTransactionPayloadSchema,
|
||||
paymentMethodOptions,
|
||||
PaymentStatus,
|
||||
paymentStatusOptions,
|
||||
claimStatusOptions,
|
||||
} from "@repo/db/types";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import { PaymentStatusSchema } from "@repo/db/types";
|
||||
import * as paymentService from "../services/paymentService";
|
||||
|
||||
const paymentFilterSchema = z.object({
|
||||
from: z.string().datetime(),
|
||||
to: z.string().datetime(),
|
||||
});
|
||||
|
||||
function parseIntOrError(input: string | undefined, name: string) {
|
||||
if (!input) throw new Error(`${name} is required`);
|
||||
const value = parseInt(input, 10);
|
||||
if (isNaN(value)) throw new Error(`${name} must be a valid number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleRouteError(
|
||||
res: Response,
|
||||
error: unknown,
|
||||
defaultMsg: string
|
||||
) {
|
||||
if (error instanceof ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
const msg = error instanceof Error ? error.message : defaultMsg;
|
||||
return res.status(500).json({ message: msg });
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/payments/recent
|
||||
router.get("/recent", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const [payments, totalCount] = await Promise.all([
|
||||
storage.getRecentPayments(limit, offset),
|
||||
storage.getTotalPaymentCount(),
|
||||
]);
|
||||
|
||||
res.status(200).json({ payments, totalCount });
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch payments:", err);
|
||||
res.status(500).json({ message: "Failed to fetch recent payments" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/claim/:claimId
|
||||
router.get(
|
||||
"/claim/:claimId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const parsedClaimId = parseIntOrError(req.params.claimId, "Claim ID");
|
||||
|
||||
const payments = await storage.getPaymentsByClaimId(parsedClaimId);
|
||||
if (!payments)
|
||||
return res.status(404).json({ message: "No payments found for claim" });
|
||||
|
||||
res.status(200).json(payments);
|
||||
} catch (error) {
|
||||
console.error("Error fetching payments:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/payments/patient/:patientId
|
||||
router.get(
|
||||
"/patient/:patientId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.patientId;
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Missing patientId" });
|
||||
}
|
||||
const patientId = parseInt(patientIdParam);
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patientId" });
|
||||
}
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
}
|
||||
|
||||
const [payments, totalCount] = await Promise.all([
|
||||
storage.getRecentPaymentsByPatientId(patientId, limit, offset),
|
||||
storage.getTotalPaymentCountByPatient(patientId),
|
||||
]);
|
||||
|
||||
res.json({ payments, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve payments for patient:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve patient payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/payments/filter
|
||||
router.get("/filter", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const validated = paymentFilterSchema.safeParse(req.query);
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid date format",
|
||||
errors: validated.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { from, to } = validated.data;
|
||||
const payments = await storage.getPaymentsByDateRange(
|
||||
new Date(from),
|
||||
new Date(to)
|
||||
);
|
||||
res.status(200).json(payments);
|
||||
} catch (err) {
|
||||
console.error("Failed to filter payments:", err);
|
||||
res.status(500).json({ message: "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/:id
|
||||
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const id = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
const payment = await storage.getPaymentById(id);
|
||||
if (!payment) return res.status(404).json({ message: "Payment not found" });
|
||||
|
||||
res.status(200).json(payment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to retrieve payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/payments/full-ocr-import
|
||||
router.post(
|
||||
"/full-ocr-import",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { rows } = req.body;
|
||||
if (!rows || !Array.isArray(rows)) {
|
||||
return res.status(400).json({ message: "Invalid OCR payload" });
|
||||
}
|
||||
|
||||
const paymentIds = await paymentService.fullOcrPaymentService.importRows(
|
||||
rows,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: "OCR rows imported successfully",
|
||||
paymentIds,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Unknown error importing OCR payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/payments/:claimId
|
||||
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const claimId = parseIntOrError(req.params.claimId, "Claim ID");
|
||||
|
||||
const validated = insertPaymentSchema.safeParse({
|
||||
...req.body,
|
||||
claimId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Validation failed",
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const payment = await storage.createPayment(validated.data);
|
||||
res.status(201).json(payment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to create payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/payments/:id
|
||||
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
const validated = newTransactionPayloadSchema.safeParse(
|
||||
req.body.data as NewTransactionPayload
|
||||
);
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Validation failed",
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const { serviceLineTransactions } = validated.data;
|
||||
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/payments/:id/pay-absolute-full-claim
|
||||
router.put(
|
||||
"/:id/pay-absolute-full-claim",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||||
if (!paymentRecord) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
// Collect service lines from either claim or direct payment(OCR based data)
|
||||
const serviceLines = paymentRecord.claim
|
||||
? paymentRecord.claim.serviceLines
|
||||
: paymentRecord.serviceLines;
|
||||
|
||||
if (!serviceLines || serviceLines.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "No service lines available for this payment" });
|
||||
}
|
||||
|
||||
const serviceLineTransactions = serviceLines
|
||||
.filter((line) => line.totalDue.gt(0))
|
||||
.map((line) => ({
|
||||
serviceLineId: line.id,
|
||||
paidAmount: line.totalDue.toNumber(),
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK,
|
||||
receivedDate: new Date(),
|
||||
notes: "Full claim payment",
|
||||
}));
|
||||
|
||||
if (serviceLineTransactions.length === 0) {
|
||||
return res.status(400).json({ message: "No outstanding balance" });
|
||||
}
|
||||
|
||||
// Use updatePayment for consistency & validation
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: "Failed to pay full claim" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/payments/:id/revert-full-claim
|
||||
router.put(
|
||||
"/:id/revert-full-claim",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||||
if (!paymentRecord) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
const serviceLines = paymentRecord.claim
|
||||
? paymentRecord.claim.serviceLines
|
||||
: paymentRecord.serviceLines;
|
||||
|
||||
if (!serviceLines || serviceLines.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "No service lines available for this payment" });
|
||||
}
|
||||
|
||||
// Build reversal transactions (negating what’s already paid/adjusted)
|
||||
const serviceLineTransactions = serviceLines
|
||||
.filter((line) => line.totalPaid.gt(0) || line.totalAdjusted.gt(0))
|
||||
.map((line) => ({
|
||||
serviceLineId: line.id,
|
||||
paidAmount: line.totalPaid.negated().toNumber(), // negative to undo
|
||||
adjustedAmount: line.totalAdjusted.negated().toNumber(),
|
||||
method: paymentMethodOptions.OTHER,
|
||||
receivedDate: new Date(),
|
||||
notes: "Reverted full claim",
|
||||
}));
|
||||
|
||||
if (serviceLineTransactions.length === 0) {
|
||||
return res.status(400).json({ message: "Nothing to revert" });
|
||||
}
|
||||
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId,
|
||||
{ isReversal: true }
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: "Failed to revert claim payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/payments/:id/status
|
||||
router.patch(
|
||||
"/:id/status",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
// Parse & coerce to PaymentStatus enum
|
||||
const rawStatus = PaymentStatusSchema.parse(req.body.data.status);
|
||||
if (
|
||||
!Object.values(paymentStatusOptions).includes(
|
||||
rawStatus as PaymentStatus
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ message: "Invalid payment status" });
|
||||
}
|
||||
const status = rawStatus as PaymentStatus;
|
||||
|
||||
// Load existing payment
|
||||
const existingPayment = await storage.getPayment(paymentId);
|
||||
if (!existingPayment) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
// If changing to VOID and linked to a claim -> update both atomically
|
||||
if (status === paymentStatusOptions.VOID && existingPayment.claimId) {
|
||||
const [updatedPayment, updatedClaim] = await prisma.$transaction([
|
||||
prisma.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { status, updatedById: userId },
|
||||
}),
|
||||
prisma.claim.update({
|
||||
where: { id: existingPayment.claimId },
|
||||
data: { status: claimStatusOptions.VOID },
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.json(updatedPayment);
|
||||
}
|
||||
|
||||
// Otherwise just update payment (use storage helper)
|
||||
const updatedPayment = await storage.updatePaymentStatus(
|
||||
paymentId,
|
||||
{ status } as any,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json(updatedPayment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update payment status";
|
||||
return res.status(500).json({ message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/payments/:id
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const id = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
// Check if payment exists and belongs to this user
|
||||
const existingPayment = await storage.getPayment(id);
|
||||
if (!existingPayment) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
if (existingPayment.userId !== req.user!.id) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Payment belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
await storage.deletePayment(id, userId);
|
||||
|
||||
res.status(200).json({ message: "Payment deleted successfully" });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to delete payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
113
apps/Backend/src/routes/staffs.ts
Executable file
113
apps/Backend/src/routes/staffs.ts
Executable file
@@ -0,0 +1,113 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
|
||||
type Staff = z.infer<typeof StaffUncheckedCreateInputObjectSchema>;
|
||||
|
||||
const staffCreateSchema = StaffUncheckedCreateInputObjectSchema;
|
||||
const staffUpdateSchema = (
|
||||
StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||
).partial();
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user!.id; // from auth middleware
|
||||
|
||||
const validatedData = staffCreateSchema.parse({
|
||||
...req.body,
|
||||
userId,
|
||||
});
|
||||
|
||||
const newStaff = await storage.createStaff(validatedData);
|
||||
res.status(200).json(newStaff);
|
||||
} catch (error) {
|
||||
console.error("Failed to create staff:", error);
|
||||
res.status(500).send("Failed to create staff");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const staff = await storage.getAllStaff();
|
||||
if (!staff) return res.status(404).send("Staff not found");
|
||||
|
||||
res.status(201).json(staff);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch staff:", error);
|
||||
res.status(500).send("Failed to fetch staff");
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const parsedStaffId = Number(req.params.id);
|
||||
if (isNaN(parsedStaffId)) {
|
||||
return res.status(400).send("Invalid staff ID");
|
||||
}
|
||||
|
||||
const validatedData = staffUpdateSchema.parse(req.body);
|
||||
const updatedStaff = await storage.updateStaff(
|
||||
parsedStaffId,
|
||||
validatedData
|
||||
);
|
||||
if (!updatedStaff) return res.status(404).send("Staff not found");
|
||||
|
||||
res.json(updatedStaff);
|
||||
} catch (error) {
|
||||
console.error("Failed to update staff:", error);
|
||||
res.status(500).send("Failed to update staff");
|
||||
}
|
||||
});
|
||||
|
||||
const parseIdOr400 = (raw: any, label: string) => {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) throw new Error(`${label} is invalid`);
|
||||
return n;
|
||||
};
|
||||
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const id = parseIdOr400(req.params.id, "Staff ID");
|
||||
const parsedStaffId = Number(req.params.id);
|
||||
if (isNaN(parsedStaffId)) {
|
||||
return res.status(400).send("Invalid staff ID");
|
||||
}
|
||||
|
||||
const existing = await storage.getStaff(id); // must include createdById
|
||||
if (!existing) return res.status(404).json({ message: "Staff not found" });
|
||||
|
||||
if (existing.userId !== userId) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Staff was created by a different user; you cannot delete it.",
|
||||
});
|
||||
}
|
||||
|
||||
const [apptCount, claimCount] = await Promise.all([
|
||||
storage.countAppointmentsByStaffId(id),
|
||||
storage.countClaimsByStaffId(id),
|
||||
]);
|
||||
|
||||
if (apptCount || claimCount) {
|
||||
return res.status(409).json({
|
||||
message: `Cannot delete staff with linked records. Appointment of this staff : ${apptCount} and Claims ${claimCount}`,
|
||||
hint: "Archive this staff, or reassign linked records, then delete.",
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteStaff(parsedStaffId);
|
||||
if (!deleted) return res.status(404).send("Staff not found");
|
||||
|
||||
res.status(200).send("Staff deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete staff:", error);
|
||||
res.status(500).send("Failed to delete staff");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
127
apps/Backend/src/routes/users.ts
Executable file
127
apps/Backend/src/routes/users.ts
Executable file
@@ -0,0 +1,127 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { UserUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Type based on shared schema
|
||||
type SelectUser = z.infer<typeof UserUncheckedCreateInputObjectSchema>;
|
||||
|
||||
// Zod validation
|
||||
const userCreateSchema = UserUncheckedCreateInputObjectSchema;
|
||||
const userUpdateSchema = (UserUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>).partial();
|
||||
|
||||
|
||||
router.get("/", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).send("Unauthorized UserId");
|
||||
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) return res.status(404).send("User not found");
|
||||
|
||||
|
||||
const { password, ...safeUser } = user;
|
||||
res.json(safeUser);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send("Failed to fetch user");
|
||||
}
|
||||
});
|
||||
|
||||
// GET: User by ID
|
||||
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) return res.status(400).send("User ID is required");
|
||||
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid user ID");
|
||||
|
||||
const user = await storage.getUser(id);
|
||||
if (!user) return res.status(404).send("User not found");
|
||||
|
||||
const { password, ...safeUser } = user;
|
||||
res.json(safeUser);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send("Failed to fetch user");
|
||||
}
|
||||
});
|
||||
|
||||
// POST: Create new user
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = userCreateSchema.parse(req.body);
|
||||
const newUser = await storage.createUser(input);
|
||||
const { password, ...safeUser } = newUser;
|
||||
res.status(201).json(safeUser);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(400).json({ error: "Invalid user data", details: err });
|
||||
}
|
||||
});
|
||||
|
||||
// Function to hash password using bcrypt
|
||||
async function hashPassword(password: string) {
|
||||
const saltRounds = 10; // Salt rounds for bcrypt
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// PUT: Update user
|
||||
router.put("/:id", async (req: Request, res: Response):Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) return res.status(400).send("User ID is required");
|
||||
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid user ID");
|
||||
|
||||
|
||||
const updates = userUpdateSchema.parse(req.body);
|
||||
|
||||
// If password is provided and non-empty, hash it
|
||||
if (updates.password && updates.password.trim() !== "") {
|
||||
updates.password = await hashPassword(updates.password);
|
||||
} else {
|
||||
// Remove password field if empty, so it won't overwrite existing password with blank
|
||||
delete updates.password;
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateUser(id, updates);
|
||||
if (!updatedUser) return res.status(404).send("User not found");
|
||||
|
||||
const { password, ...safeUser } = updatedUser;
|
||||
res.json(safeUser);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(400).json({ error: "Invalid update data", details: err });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE: Delete user
|
||||
router.delete("/:id", async (req: Request, res: Response):Promise<any> => {
|
||||
try {
|
||||
const idParam = req.params.id;
|
||||
if (!idParam) return res.status(400).send("User ID is required");
|
||||
|
||||
const id = parseInt(idParam);
|
||||
if (isNaN(id)) return res.status(400).send("Invalid user ID");
|
||||
|
||||
const success = await storage.deleteUser(id);
|
||||
if (!success) return res.status(404).send("User not found");
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send("Failed to delete user");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
85
apps/Backend/src/services/databaseBackupService.ts
Executable file
85
apps/Backend/src/services/databaseBackupService.ts
Executable file
@@ -0,0 +1,85 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import archiver from "archiver";
|
||||
|
||||
function safeRmDir(dir: string) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
interface BackupToPathParams {
|
||||
destinationPath: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export async function backupDatabaseToPath({
|
||||
destinationPath,
|
||||
filename,
|
||||
}: BackupToPathParams): Promise<void> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_"));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const pgDump = spawn(
|
||||
"pg_dump",
|
||||
[
|
||||
"-Fd",
|
||||
"-j",
|
||||
"4",
|
||||
"--no-acl",
|
||||
"--no-owner",
|
||||
"-h",
|
||||
process.env.DB_HOST || "localhost",
|
||||
"-U",
|
||||
process.env.DB_USER || "postgres",
|
||||
process.env.DB_NAME || "dental_db",
|
||||
"-f",
|
||||
tmpDir,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.DB_PASSWORD,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let pgError = "";
|
||||
|
||||
pgDump.stderr.on("data", (d) => (pgError += d.toString()));
|
||||
|
||||
pgDump.on("close", async (code) => {
|
||||
if (code !== 0) {
|
||||
safeRmDir(tmpDir);
|
||||
return reject(new Error(pgError || "pg_dump failed"));
|
||||
}
|
||||
|
||||
const outputFile = path.join(destinationPath, filename);
|
||||
const outputStream = fs.createWriteStream(outputFile);
|
||||
|
||||
const archive = archiver("zip");
|
||||
|
||||
outputStream.on("error", (err) => {
|
||||
safeRmDir(tmpDir);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.on("error", (err) => {
|
||||
safeRmDir(tmpDir);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(outputStream);
|
||||
archive.directory(tmpDir + path.sep, false);
|
||||
|
||||
archive.finalize();
|
||||
|
||||
archive.on("end", () => {
|
||||
safeRmDir(tmpDir);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
29
apps/Backend/src/services/patientDataExtractorService.ts
Executable file
29
apps/Backend/src/services/patientDataExtractorService.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
|
||||
export interface ExtractedData {
|
||||
name?: string;
|
||||
memberId?: string;
|
||||
dob?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default async function forwardToPatientDataExtractorService(
|
||||
file: Express.Multer.File
|
||||
): Promise<ExtractedData> {
|
||||
const form = new FormData();
|
||||
form.append("pdf", file.buffer, {
|
||||
filename: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
|
||||
const response = await axios.post<ExtractedData>(
|
||||
"http://localhost:5001/extract",
|
||||
form,
|
||||
{
|
||||
headers: form.getHeaders(),
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
34
apps/Backend/src/services/paymentOCRService.ts
Executable file
34
apps/Backend/src/services/paymentOCRService.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
|
||||
export async function forwardToPaymentOCRService(
|
||||
files: Express.Multer.File | Express.Multer.File[]
|
||||
): Promise<any> {
|
||||
const arr = Array.isArray(files) ? files : [files];
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of arr) {
|
||||
form.append("files", f.buffer, {
|
||||
filename: f.originalname,
|
||||
contentType: f.mimetype, // image/jpeg, image/png, image/tiff, etc.
|
||||
knownLength: f.size,
|
||||
});
|
||||
}
|
||||
|
||||
const url = `http://localhost:5003/extract/json`;
|
||||
|
||||
try {
|
||||
const resp = await axios.post<{ rows: any }>(url, form, {
|
||||
headers: form.getHeaders(),
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
timeout: 120000, // OCR can be heavy; adjust as needed
|
||||
});
|
||||
return resp.data?.rows ?? [];
|
||||
} catch (err: any) {
|
||||
// Bubble up a useful error message
|
||||
const status = err?.response?.status;
|
||||
const detail = err?.response?.data?.detail || err?.message || "Unknown error";
|
||||
throw new Error(`Payment OCR request failed${status ? ` (${status})` : ""}: ${detail}`);
|
||||
}
|
||||
}
|
||||
284
apps/Backend/src/services/paymentService.ts
Executable file
284
apps/Backend/src/services/paymentService.ts
Executable file
@@ -0,0 +1,284 @@
|
||||
import Decimal from "decimal.js";
|
||||
import {
|
||||
NewTransactionPayload,
|
||||
OcrRow,
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
ClaimStatus,
|
||||
} from "@repo/db/types";
|
||||
import { storage } from "../storage";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import { convertOCRDate } from "../utils/dateUtils";
|
||||
|
||||
/**
|
||||
* Validate transactions against a payment record
|
||||
*/
|
||||
export async function validateTransactions(
|
||||
paymentId: number,
|
||||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||||
options?: { isReversal?: boolean }
|
||||
) {
|
||||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||||
if (!paymentRecord) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
// Choose service lines from claim if present, otherwise direct payment service lines(OCR Based datas)
|
||||
const serviceLines = paymentRecord.claim
|
||||
? paymentRecord.claim.serviceLines
|
||||
: paymentRecord.serviceLines;
|
||||
|
||||
if (!serviceLines || serviceLines.length === 0) {
|
||||
throw new Error("No service lines available for this payment");
|
||||
}
|
||||
|
||||
for (const txn of serviceLineTransactions) {
|
||||
const line = serviceLines.find((sl) => sl.id === txn.serviceLineId);
|
||||
|
||||
if (!line) {
|
||||
throw new Error(`Invalid service line: ${txn.serviceLineId}`);
|
||||
}
|
||||
|
||||
const paidAmount = new Decimal(txn.paidAmount ?? 0);
|
||||
const adjustedAmount = new Decimal(txn.adjustedAmount ?? 0);
|
||||
|
||||
if (!options?.isReversal && (paidAmount.lt(0) || adjustedAmount.lt(0))) {
|
||||
throw new Error("Amounts cannot be negative");
|
||||
}
|
||||
|
||||
if (paidAmount.eq(0) && adjustedAmount.eq(0)) {
|
||||
throw new Error("Must provide a payment or adjustment");
|
||||
}
|
||||
if (!options?.isReversal && paidAmount.gt(line.totalDue)) {
|
||||
throw new Error(
|
||||
`Paid amount exceeds due for service line ${txn.serviceLineId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return paymentRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply transactions to a payment & recalc totals
|
||||
*/
|
||||
export async function applyTransactions(
|
||||
paymentId: number,
|
||||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||||
userId: number
|
||||
): Promise<Payment> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// 1. Insert service line transactions + recalculate each serviceLines
|
||||
for (const txn of serviceLineTransactions) {
|
||||
await tx.serviceLineTransaction.create({
|
||||
data: {
|
||||
paymentId,
|
||||
serviceLineId: txn.serviceLineId,
|
||||
transactionId: txn.transactionId,
|
||||
paidAmount: new Decimal(txn.paidAmount),
|
||||
adjustedAmount: new Decimal(txn.adjustedAmount || 0),
|
||||
method: txn.method,
|
||||
receivedDate: txn.receivedDate,
|
||||
payerName: txn.payerName,
|
||||
notes: txn.notes,
|
||||
},
|
||||
});
|
||||
|
||||
// Recalculate Claim - serviceLines model totals and updates along with Claim-serviceLine status
|
||||
const aggLine = await tx.serviceLineTransaction.aggregate({
|
||||
_sum: { paidAmount: true, adjustedAmount: true },
|
||||
where: { serviceLineId: txn.serviceLineId },
|
||||
});
|
||||
|
||||
const serviceLine = await tx.serviceLine.findUniqueOrThrow({
|
||||
where: { id: txn.serviceLineId },
|
||||
select: { totalBilled: true },
|
||||
});
|
||||
|
||||
const totalPaid = aggLine._sum.paidAmount || new Decimal(0);
|
||||
const totalAdjusted = aggLine._sum.adjustedAmount || new Decimal(0);
|
||||
const totalDue = serviceLine.totalBilled
|
||||
.minus(totalPaid)
|
||||
.minus(totalAdjusted);
|
||||
|
||||
await tx.serviceLine.update({
|
||||
where: { id: txn.serviceLineId },
|
||||
data: {
|
||||
totalPaid,
|
||||
totalAdjusted,
|
||||
totalDue,
|
||||
status:
|
||||
totalDue.lte(0) && totalPaid.gt(0)
|
||||
? "PAID"
|
||||
: totalPaid.gt(0)
|
||||
? "PARTIALLY_PAID"
|
||||
: "UNPAID",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Recalc payment model totals based on serviceLineTransactions, and update PaymentStatus.
|
||||
const aggPayment = await tx.serviceLineTransaction.aggregate({
|
||||
_sum: { paidAmount: true, adjustedAmount: true },
|
||||
where: { paymentId },
|
||||
});
|
||||
|
||||
const payment = await tx.payment.findUniqueOrThrow({
|
||||
where: { id: paymentId },
|
||||
select: { totalBilled: true },
|
||||
});
|
||||
|
||||
const totalPaid = aggPayment._sum.paidAmount || new Decimal(0);
|
||||
const totalAdjusted = aggPayment._sum.adjustedAmount || new Decimal(0);
|
||||
const totalDue = payment.totalBilled.minus(totalPaid).minus(totalAdjusted);
|
||||
|
||||
let status: PaymentStatus;
|
||||
if (totalDue.lte(0) && totalPaid.gt(0)) status = "PAID";
|
||||
else if (totalPaid.gt(0)) status = "PARTIALLY_PAID";
|
||||
else status = "PENDING";
|
||||
|
||||
const updatedPayment = await tx.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { totalPaid, totalAdjusted, totalDue, status, updatedById: userId },
|
||||
});
|
||||
|
||||
// 3. Update Claim Model Status based on serviceLineTransaction and Payment values.(as they hold the same values
|
||||
// as per, ServiceLine.totalPaid and totalAdjusted and Claim.totalBilled) Hence not fetching unneccessary.
|
||||
const claimId = updatedPayment.claimId ?? null;
|
||||
if (claimId) {
|
||||
let newClaimStatus: ClaimStatus;
|
||||
if (totalDue.lte(0) && totalPaid.gt(0)) newClaimStatus = "APPROVED";
|
||||
else newClaimStatus = "PENDING";
|
||||
|
||||
await tx.claim.update({
|
||||
where: { id: claimId },
|
||||
data: { status: newClaimStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return updatedPayment;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for updating payments
|
||||
*/
|
||||
export async function updatePayment(
|
||||
paymentId: number,
|
||||
serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"],
|
||||
userId: number,
|
||||
options?: { isReversal?: boolean }
|
||||
): Promise<Payment> {
|
||||
await validateTransactions(paymentId, serviceLineTransactions, options);
|
||||
return applyTransactions(paymentId, serviceLineTransactions, userId);
|
||||
}
|
||||
|
||||
// handling full-ocr-payments-import
|
||||
|
||||
export const fullOcrPaymentService = {
|
||||
async importRows(rows: OcrRow[], userId: number) {
|
||||
const results: number[] = [];
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
try {
|
||||
if (!row.patientName || !row.insuranceId) {
|
||||
throw new Error(
|
||||
`Row ${index + 1}: missing patientName or insuranceId`
|
||||
);
|
||||
}
|
||||
if (!row.procedureCode) {
|
||||
throw new Error(`Row ${index + 1}: missing procedureCode`);
|
||||
}
|
||||
|
||||
const billed = new Decimal(row.totalBilled ?? 0);
|
||||
const allowed = new Decimal(row.totalAllowed ?? row.totalBilled ?? 0);
|
||||
const paid = new Decimal(row.totalPaid ?? 0);
|
||||
|
||||
const adjusted = billed.minus(allowed); // write-off
|
||||
|
||||
// Step 1–3 in a transaction
|
||||
const { paymentId, serviceLineId } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// 1. Find or create patient
|
||||
let patient = await tx.patient.findFirst({
|
||||
where: { insuranceId: row.insuranceId.toString() },
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
const [firstNameRaw, ...rest] = (row.patientName ?? "")
|
||||
.trim()
|
||||
.split(" ");
|
||||
const firstName = firstNameRaw || "Unknown";
|
||||
const lastName = rest.length > 0 ? rest.join(" ") : "Unknown";
|
||||
|
||||
patient = await tx.patient.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceId: row.insuranceId.toString(),
|
||||
dateOfBirth: new Date(Date.UTC(1900, 0, 1)), // fallback (1900, jan, 1)
|
||||
gender: "",
|
||||
phone: "",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create payment (claimId null) — IMPORTANT: start with zeros, due = billed
|
||||
const payment = await tx.payment.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
totalBilled: billed,
|
||||
totalPaid: new Decimal(0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: billed,
|
||||
status: "PENDING", // updatePayment will fix it
|
||||
notes: `OCR import from ${row.sourceFile ?? "Unknown file"}`,
|
||||
icn: row.icn ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create service line — IMPORTANT: start with zeros, due = billed
|
||||
const serviceLine = await tx.serviceLine.create({
|
||||
data: {
|
||||
paymentId: payment.id,
|
||||
procedureCode: row.procedureCode,
|
||||
toothNumber: row.toothNumber ?? null,
|
||||
toothSurface: row.toothSurface ?? null,
|
||||
procedureDate: convertOCRDate(row.procedureDate),
|
||||
totalBilled: billed,
|
||||
totalPaid: new Decimal(0),
|
||||
totalAdjusted: new Decimal(0),
|
||||
totalDue: billed,
|
||||
},
|
||||
});
|
||||
|
||||
return { paymentId: payment.id, serviceLineId: serviceLine.id };
|
||||
}
|
||||
);
|
||||
|
||||
// Step 4: AFTER commit, recalc using updatePayment (global prisma can see it now)
|
||||
// Build transaction & let updatePayment handle recalculation
|
||||
const txn = {
|
||||
serviceLineId,
|
||||
paidAmount: paid.toNumber(),
|
||||
adjustedAmount: adjusted.toNumber(),
|
||||
method: "OTHER" as PaymentMethod,
|
||||
receivedDate: new Date(),
|
||||
notes: "OCR import",
|
||||
};
|
||||
|
||||
await updatePayment(paymentId, [txn], userId);
|
||||
|
||||
results.push(paymentId);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to import OCR row ${index + 1}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
52
apps/Backend/src/services/seleniumClaimClient.ts
Executable file
52
apps/Backend/src/services/seleniumClaimClient.ts
Executable file
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SeleniumPayload {
|
||||
claim: any;
|
||||
pdfs: {
|
||||
originalname: string;
|
||||
bufferBase64: string;
|
||||
}[];
|
||||
images: {
|
||||
originalname: string;
|
||||
bufferBase64: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumClaimAgent(
|
||||
claimData: any,
|
||||
files: Express.Multer.File[]
|
||||
): Promise<any> {
|
||||
const pdfs = files
|
||||
.filter((file) => file.mimetype === "application/pdf")
|
||||
.map((file) => ({
|
||||
originalname: file.originalname,
|
||||
bufferBase64: file.buffer.toString("base64"),
|
||||
}));
|
||||
|
||||
const images = files
|
||||
.filter((file) => file.mimetype.startsWith("image/"))
|
||||
.map((file) => ({
|
||||
originalname: file.originalname,
|
||||
bufferBase64: file.buffer.toString("base64"),
|
||||
}));
|
||||
|
||||
const payload: SeleniumPayload = {
|
||||
claim: claimData,
|
||||
pdfs,
|
||||
images,
|
||||
};
|
||||
|
||||
const result = await axios.post(
|
||||
"http://localhost:5002/claimsubmit",
|
||||
payload
|
||||
);
|
||||
if (result.data.status === "error") {
|
||||
const errorMsg =
|
||||
typeof result.data.message === "string"
|
||||
? result.data.message
|
||||
: result.data.message?.msg || "Selenium agent error";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
122
apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts
Executable file
122
apps/Backend/src/services/seleniumDdmaInsuranceEligibilityClient.ts
Executable file
@@ -0,0 +1,122 @@
|
||||
import axios from "axios";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
export interface SeleniumPayload {
|
||||
data: any;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
|
||||
|
||||
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: SELENIUM_AGENT_BASE,
|
||||
timeout: 5 * 60 * 1000,
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
validateStatus: (s) => s >= 200 && s < 600,
|
||||
});
|
||||
|
||||
async function requestWithRetries(
|
||||
config: any,
|
||||
retries = 4,
|
||||
baseBackoffMs = 300
|
||||
) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const r = await client.request(config);
|
||||
if (![502, 503, 504].includes(r.status)) return r;
|
||||
console.warn(
|
||||
`[selenium-client] retryable HTTP status ${r.status} (attempt ${attempt})`
|
||||
);
|
||||
} catch (err: any) {
|
||||
const code = err?.code;
|
||||
const isTransient =
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EPIPE" ||
|
||||
code === "ETIMEDOUT";
|
||||
if (!isTransient) throw err;
|
||||
console.warn(
|
||||
`[selenium-client] transient network error ${code} (attempt ${attempt})`
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||
}
|
||||
// final attempt (let exception bubble if it fails)
|
||||
return client.request(config);
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumDdmaEligibilityAgent(
|
||||
insuranceEligibilityData: any
|
||||
): Promise<any> {
|
||||
const payload = { data: insuranceEligibilityData };
|
||||
const url = `/ddma-eligibility`;
|
||||
log("selenium-client", "POST ddma-eligibility", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
keys: Object.keys(payload),
|
||||
});
|
||||
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||
log("selenium-client", "agent response", {
|
||||
status: r.status,
|
||||
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||
});
|
||||
if (r.status >= 500)
|
||||
throw new Error(`Selenium agent server error: ${r.status}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function forwardOtpToSeleniumDdmaAgent(
|
||||
sessionId: string,
|
||||
otp: string
|
||||
): Promise<any> {
|
||||
const url = `/submit-otp`;
|
||||
log("selenium-client", "POST submit-otp", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
sessionId,
|
||||
});
|
||||
const r = await requestWithRetries(
|
||||
{ url, method: "POST", data: { session_id: sessionId, otp } },
|
||||
4
|
||||
);
|
||||
log("selenium-client", "submit-otp response", {
|
||||
status: r.status,
|
||||
data: r.data,
|
||||
});
|
||||
if (r.status >= 500)
|
||||
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function getSeleniumDdmaSessionStatus(
|
||||
sessionId: string
|
||||
): Promise<any> {
|
||||
const url = `/session/${sessionId}/status`;
|
||||
log("selenium-client", "GET session status", {
|
||||
url: SELENIUM_AGENT_BASE + url,
|
||||
sessionId,
|
||||
});
|
||||
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||
log("selenium-client", "session status response", {
|
||||
status: r.status,
|
||||
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||
});
|
||||
if (r.status === 404) {
|
||||
const e: any = new Error("not_found");
|
||||
e.response = { status: 404, data: r.data };
|
||||
throw e;
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
52
apps/Backend/src/services/seleniumInsuranceClaimPreAuthClient.ts
Executable file
52
apps/Backend/src/services/seleniumInsuranceClaimPreAuthClient.ts
Executable file
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SeleniumPayload {
|
||||
claim: any;
|
||||
pdfs: {
|
||||
originalname: string;
|
||||
bufferBase64: string;
|
||||
}[];
|
||||
images: {
|
||||
originalname: string;
|
||||
bufferBase64: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumClaimPreAuthAgent(
|
||||
claimData: any,
|
||||
files: Express.Multer.File[]
|
||||
): Promise<any> {
|
||||
const pdfs = files
|
||||
.filter((file) => file.mimetype === "application/pdf")
|
||||
.map((file) => ({
|
||||
originalname: file.originalname,
|
||||
bufferBase64: file.buffer.toString("base64"),
|
||||
}));
|
||||
|
||||
const images = files
|
||||
.filter((file) => file.mimetype.startsWith("image/"))
|
||||
.map((file) => ({
|
||||
originalname: file.originalname,
|
||||
bufferBase64: file.buffer.toString("base64"),
|
||||
}));
|
||||
|
||||
const payload: SeleniumPayload = {
|
||||
claim: claimData,
|
||||
pdfs,
|
||||
images,
|
||||
};
|
||||
|
||||
const result = await axios.post(
|
||||
"http://localhost:5002/claim-pre-auth",
|
||||
payload
|
||||
);
|
||||
if (result.data.status === "error") {
|
||||
const errorMsg =
|
||||
typeof result.data.message === "string"
|
||||
? result.data.message
|
||||
: result.data.message?.msg || "Selenium agent error";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
27
apps/Backend/src/services/seleniumInsuranceClaimStatusClient.ts
Executable file
27
apps/Backend/src/services/seleniumInsuranceClaimStatusClient.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SeleniumPayload {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumInsuranceClaimStatusAgent(
|
||||
insuranceClaimStatusData: any
|
||||
): Promise<any> {
|
||||
const payload: SeleniumPayload = {
|
||||
data: insuranceClaimStatusData,
|
||||
};
|
||||
|
||||
const result = await axios.post(
|
||||
"http://localhost:5002/claim-status-check",
|
||||
payload
|
||||
);
|
||||
if (result.data.status === "error") {
|
||||
const errorMsg =
|
||||
typeof result.data.message === "string"
|
||||
? result.data.message
|
||||
: result.data.message?.msg || "Selenium agent error";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
27
apps/Backend/src/services/seleniumInsuranceEligibilityClient.ts
Executable file
27
apps/Backend/src/services/seleniumInsuranceEligibilityClient.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SeleniumPayload {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumInsuranceEligibilityAgent(
|
||||
insuranceEligibilityData: any
|
||||
): Promise<any> {
|
||||
const payload: SeleniumPayload = {
|
||||
data: insuranceEligibilityData,
|
||||
};
|
||||
|
||||
const result = await axios.post(
|
||||
"http://localhost:5002/eligibility-check",
|
||||
payload
|
||||
);
|
||||
if (result.data.status === "error") {
|
||||
const errorMsg =
|
||||
typeof result.data.message === "string"
|
||||
? result.data.message
|
||||
: result.data.message?.msg || "Selenium agent error";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
53
apps/Backend/src/socket.ts
Executable file
53
apps/Backend/src/socket.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import { Server as HttpServer } from "http";
|
||||
import { Server, Socket } from "socket.io";
|
||||
|
||||
let io: Server | null = null;
|
||||
|
||||
export function initSocket(server: HttpServer) {
|
||||
const NODE_ENV = (
|
||||
process.env.NODE_ENV ||
|
||||
process.env.ENV ||
|
||||
"development"
|
||||
).toLowerCase();
|
||||
|
||||
const rawFrontendUrls =
|
||||
process.env.FRONTEND_URLS || process.env.FRONTEND_URL || "";
|
||||
const FRONTEND_URLS = rawFrontendUrls
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// In dev: allow all origins
|
||||
// In prod: restrict to FRONTEND_URLS if provided
|
||||
const corsOrigin =
|
||||
NODE_ENV !== "production"
|
||||
? true
|
||||
: FRONTEND_URLS.length > 0
|
||||
? FRONTEND_URLS
|
||||
: false; // no origins allowed if none configured in prod
|
||||
|
||||
io = new Server(server, {
|
||||
cors: {
|
||||
origin: corsOrigin,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
io.on("connection", (socket: Socket) => {
|
||||
console.log("🔌 Socket connected:", socket.id);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("🔌 Socket disconnected:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Optional: log low-level engine errors for debugging
|
||||
io.engine.on("connection_error", (err) => {
|
||||
console.error("Socket engine connection_error:", err);
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
export { io };
|
||||
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Executable file
100
apps/Backend/src/storage/appointment-procedures-storage.ts
Executable file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Appointment,
|
||||
AppointmentProcedure,
|
||||
InsertAppointmentProcedure,
|
||||
Patient,
|
||||
UpdateAppointmentProcedure,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IAppointmentProceduresStorage {
|
||||
getByAppointmentId(appointmentId: number): Promise<AppointmentProcedure[]>;
|
||||
getPrefillDataByAppointmentId(appointmentId: number): Promise<{
|
||||
appointment: Appointment;
|
||||
patient: Patient;
|
||||
procedures: AppointmentProcedure[];
|
||||
} | null>;
|
||||
|
||||
createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure>;
|
||||
createProceduresBulk(data: InsertAppointmentProcedure[]): Promise<number>;
|
||||
updateProcedure(
|
||||
id: number,
|
||||
data: UpdateAppointmentProcedure
|
||||
): Promise<AppointmentProcedure>;
|
||||
deleteProcedure(id: number): Promise<void>;
|
||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||
}
|
||||
|
||||
export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
async getByAppointmentId(
|
||||
appointmentId: number
|
||||
): Promise<AppointmentProcedure[]> {
|
||||
return db.appointmentProcedure.findMany({
|
||||
where: { appointmentId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async getPrefillDataByAppointmentId(appointmentId: number) {
|
||||
const appointment = await db.appointment.findUnique({
|
||||
where: { id: appointmentId },
|
||||
include: {
|
||||
patient: true,
|
||||
procedures: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
appointment,
|
||||
patient: appointment.patient,
|
||||
procedures: appointment.procedures,
|
||||
};
|
||||
},
|
||||
|
||||
async createProcedure(
|
||||
data: InsertAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
return db.appointmentProcedure.create({
|
||||
data: data as AppointmentProcedure,
|
||||
});
|
||||
},
|
||||
|
||||
async createProceduresBulk(
|
||||
data: InsertAppointmentProcedure[]
|
||||
): Promise<number> {
|
||||
const result = await db.appointmentProcedure.createMany({
|
||||
data: data as any[],
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async updateProcedure(
|
||||
id: number,
|
||||
data: UpdateAppointmentProcedure
|
||||
): Promise<AppointmentProcedure> {
|
||||
return db.appointmentProcedure.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteProcedure(id: number): Promise<void> {
|
||||
await db.appointmentProcedure.delete({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
|
||||
async clearByAppointmentId(appointmentId: number): Promise<void> {
|
||||
await db.appointmentProcedure.deleteMany({
|
||||
where: { appointmentId },
|
||||
});
|
||||
},
|
||||
};
|
||||
226
apps/Backend/src/storage/appointments-storage.ts
Executable file
226
apps/Backend/src/storage/appointments-storage.ts
Executable file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
Patient,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
getAppointment(id: number): Promise<Appointment | undefined>;
|
||||
getAllAppointments(): Promise<Appointment[]>;
|
||||
getAppointmentsByUserId(userId: number): Promise<Appointment[]>;
|
||||
getAppointmentsByPatientId(patientId: number): Promise<Appointment[]>;
|
||||
getPatientFromAppointmentId(
|
||||
appointmentId: number
|
||||
): Promise<Patient | undefined>;
|
||||
getRecentAppointments(limit: number, offset: number): Promise<Appointment[]>;
|
||||
getAppointmentsOnRange(start: Date, end: Date): Promise<Appointment[]>;
|
||||
createAppointment(appointment: InsertAppointment): Promise<Appointment>;
|
||||
updateAppointment(
|
||||
id: number,
|
||||
appointment: UpdateAppointment
|
||||
): Promise<Appointment>;
|
||||
deleteAppointment(id: number): Promise<void>;
|
||||
getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
startTime: string
|
||||
): Promise<Appointment | undefined>;
|
||||
getStaffAppointmentByDateTime(
|
||||
staffId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId?: number
|
||||
): Promise<Appointment | undefined>;
|
||||
getPatientConflictAppointment(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId: number
|
||||
): Promise<Appointment | undefined>;
|
||||
getStaffConflictAppointment(
|
||||
staffId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId: number
|
||||
): Promise<Appointment | undefined>;
|
||||
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<Appointment[]>;
|
||||
}
|
||||
|
||||
export const appointmentsStorage: IStorage = {
|
||||
async getAppointment(id: number): Promise<Appointment | undefined> {
|
||||
const appointment = await db.appointment.findUnique({ where: { id } });
|
||||
return appointment ?? undefined;
|
||||
},
|
||||
|
||||
async getAllAppointments(): Promise<Appointment[]> {
|
||||
return await db.appointment.findMany();
|
||||
},
|
||||
|
||||
async getAppointmentsByUserId(userId: number): Promise<Appointment[]> {
|
||||
return await db.appointment.findMany({ where: { userId } });
|
||||
},
|
||||
|
||||
async getAppointmentsByPatientId(patientId: number): Promise<Appointment[]> {
|
||||
return await db.appointment.findMany({ where: { patientId } });
|
||||
},
|
||||
|
||||
async getPatientFromAppointmentId(
|
||||
appointmentId: number
|
||||
): Promise<Patient | undefined> {
|
||||
const appointment = await db.appointment.findUnique({
|
||||
where: { id: appointmentId },
|
||||
include: { patient: true },
|
||||
});
|
||||
return appointment?.patient ?? undefined;
|
||||
},
|
||||
|
||||
async getAppointmentsOnRange(start: Date, end: Date): Promise<Appointment[]> {
|
||||
return db.appointment.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecentAppointments(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Appointment[]> {
|
||||
return db.appointment.findMany({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async createAppointment(
|
||||
appointment: InsertAppointment
|
||||
): Promise<Appointment> {
|
||||
return await db.appointment.create({ data: appointment as Appointment });
|
||||
},
|
||||
|
||||
async updateAppointment(
|
||||
id: number,
|
||||
updateData: UpdateAppointment
|
||||
): Promise<Appointment> {
|
||||
try {
|
||||
return await db.appointment.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Appointment with ID ${id} not found`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAppointment(id: number): Promise<void> {
|
||||
try {
|
||||
await db.appointment.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw new Error(`Appointment with ID ${id} not found`);
|
||||
}
|
||||
},
|
||||
|
||||
async getPatientAppointmentByDateTime(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
startTime: string
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: {
|
||||
patientId,
|
||||
date,
|
||||
startTime,
|
||||
},
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getStaffAppointmentByDateTime(
|
||||
staffId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId?: number
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: {
|
||||
staffId,
|
||||
date,
|
||||
startTime,
|
||||
NOT: excludeId ? { id: excludeId } : undefined,
|
||||
},
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getPatientConflictAppointment(
|
||||
patientId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId: number
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: {
|
||||
patientId,
|
||||
date,
|
||||
startTime,
|
||||
NOT: { id: excludeId },
|
||||
},
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getStaffConflictAppointment(
|
||||
staffId: number,
|
||||
date: Date,
|
||||
startTime: string,
|
||||
excludeId: number
|
||||
): Promise<Appointment | undefined> {
|
||||
return (
|
||||
(await db.appointment.findFirst({
|
||||
where: {
|
||||
staffId,
|
||||
date,
|
||||
startTime,
|
||||
NOT: { id: excludeId },
|
||||
},
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* getAppointmentsByDateForUser
|
||||
* dateStr expected as "YYYY-MM-DD" (same string your frontend sends)
|
||||
* returns appointments for that date (local midnight-to-midnight) filtered by userId
|
||||
*/
|
||||
async getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<Appointment[]> {
|
||||
// defensive parsing — if invalid, throw so caller can handle
|
||||
const start = new Date(dateStr);
|
||||
if (Number.isNaN(start.getTime())) {
|
||||
throw new Error(`Invalid date string passed to getAppointmentsByDateForUser: ${dateStr}`);
|
||||
}
|
||||
// create exclusive end (next day midnight)
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 1);
|
||||
|
||||
return db.appointment.findMany({
|
||||
where: {
|
||||
userId,
|
||||
date: {
|
||||
gte: start,
|
||||
lt: end,
|
||||
},
|
||||
},
|
||||
orderBy: { startTime: "asc" },
|
||||
});
|
||||
}
|
||||
};
|
||||
111
apps/Backend/src/storage/claims-storage.ts
Executable file
111
apps/Backend/src/storage/claims-storage.ts
Executable file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Claim,
|
||||
ClaimStatus,
|
||||
ClaimWithServiceLines,
|
||||
InsertClaim,
|
||||
UpdateClaim,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
getClaim(id: number): Promise<Claim | undefined>;
|
||||
getRecentClaimsByPatientId(
|
||||
patientId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<ClaimWithServiceLines[]>;
|
||||
getTotalClaimCountByPatient(patientId: number): Promise<number>;
|
||||
getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]>;
|
||||
getRecentClaims(limit: number, offset: number): Promise<Claim[]>;
|
||||
getTotalClaimCount(): Promise<number>;
|
||||
createClaim(claim: InsertClaim): Promise<Claim>;
|
||||
updateClaim(id: number, updates: UpdateClaim): Promise<Claim>;
|
||||
updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim>;
|
||||
deleteClaim(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export const claimsStorage: IStorage = {
|
||||
async getClaim(id: number): Promise<Claim | undefined> {
|
||||
const claim = await db.claim.findUnique({ where: { id } });
|
||||
return claim ?? undefined;
|
||||
},
|
||||
|
||||
async getRecentClaimsByPatientId(
|
||||
patientId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<ClaimWithServiceLines[]> {
|
||||
return db.claim.findMany({
|
||||
where: { patientId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
serviceLines: true,
|
||||
staff: true,
|
||||
claimFiles: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async getTotalClaimCountByPatient(patientId: number): Promise<number> {
|
||||
return db.claim.count({
|
||||
where: { patientId },
|
||||
});
|
||||
},
|
||||
|
||||
async getClaimsByAppointmentId(appointmentId: number): Promise<Claim[]> {
|
||||
return await db.claim.findMany({ where: { appointmentId } });
|
||||
},
|
||||
|
||||
async getRecentClaims(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<ClaimWithServiceLines[]> {
|
||||
return db.claim.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: { serviceLines: true, staff: true, claimFiles: true },
|
||||
});
|
||||
},
|
||||
|
||||
async getTotalClaimCount(): Promise<number> {
|
||||
return db.claim.count();
|
||||
},
|
||||
|
||||
async createClaim(claim: InsertClaim): Promise<Claim> {
|
||||
return await db.claim.create({ data: claim as Claim });
|
||||
},
|
||||
|
||||
async updateClaim(id: number, updates: UpdateClaim): Promise<Claim> {
|
||||
try {
|
||||
return await db.claim.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Claim with ID ${id} not found`);
|
||||
}
|
||||
},
|
||||
|
||||
async updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim> {
|
||||
const existing = await db.claim.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error("Claim not found");
|
||||
}
|
||||
|
||||
return db.claim.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
},
|
||||
|
||||
async deleteClaim(id: number): Promise<void> {
|
||||
try {
|
||||
await db.claim.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw new Error(`Claim with ID ${id} not found`);
|
||||
}
|
||||
},
|
||||
};
|
||||
493
apps/Backend/src/storage/cloudStorage-storage.ts
Executable file
493
apps/Backend/src/storage/cloudStorage-storage.ts
Executable file
@@ -0,0 +1,493 @@
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { CloudFolder, CloudFile } from "@repo/db/types";
|
||||
import { serializeFile } from "../utils/prismaFileUtils";
|
||||
|
||||
/**
|
||||
* Cloud storage implementation
|
||||
*
|
||||
* - Clear, self-describing method names
|
||||
* - Folder timestamp propagation helper: updateFolderTimestampsRecursively
|
||||
* - File upload lifecycle: initializeFileUpload -> appendFileChunk -> finalizeFileUpload
|
||||
*/
|
||||
|
||||
/* ------------------------------- Helpers ------------------------------- */
|
||||
async function updateFolderTimestampsRecursively(folderId: number | null) {
|
||||
if (folderId == null) return;
|
||||
let currentId: number | null = folderId;
|
||||
const MAX_DEPTH = 50;
|
||||
let depth = 0;
|
||||
|
||||
while (currentId != null && depth < MAX_DEPTH) {
|
||||
depth += 1;
|
||||
try {
|
||||
// touch updatedAt and fetch parentId
|
||||
const row = (await db.cloudFolder.update({
|
||||
where: { id: currentId },
|
||||
data: { updatedAt: new Date() },
|
||||
select: { parentId: true },
|
||||
})) as { parentId: number | null };
|
||||
|
||||
currentId = row.parentId ?? null;
|
||||
} catch (err: any) {
|
||||
// Stop walking if folder removed concurrently (Prisma P2025)
|
||||
if (err?.code === "P2025") break;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- IStorage ------------------------------- */
|
||||
export interface IStorage {
|
||||
// Folders
|
||||
getFolder(id: number): Promise<CloudFolder | null>;
|
||||
listRecentFolders(
|
||||
limit: number,
|
||||
offset: number,
|
||||
parentId?: number | null
|
||||
): Promise<CloudFolder[]>;
|
||||
countFoldersByParent(parentId: number | null): Promise<number>;
|
||||
countFolders(filter?: {
|
||||
userId?: number;
|
||||
nameContains?: string | null;
|
||||
}): Promise<number>;
|
||||
createFolder(
|
||||
userId: number,
|
||||
name: string,
|
||||
parentId?: number | null
|
||||
): Promise<CloudFolder>;
|
||||
updateFolder(
|
||||
id: number,
|
||||
updates: Partial<{ name?: string; parentId?: number | null }>
|
||||
): Promise<CloudFolder | null>;
|
||||
deleteFolder(id: number): Promise<boolean>;
|
||||
|
||||
// Files
|
||||
getFile(id: number): Promise<CloudFile | null>;
|
||||
listFilesInFolder(
|
||||
folderId: number | null,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<CloudFile[]>;
|
||||
initializeFileUpload(
|
||||
userId: number,
|
||||
name: string,
|
||||
mimeType?: string | null,
|
||||
expectedSize?: bigint | null,
|
||||
totalChunks?: number | null,
|
||||
folderId?: number | null
|
||||
): Promise<CloudFile>;
|
||||
appendFileChunk(fileId: number, seq: number, data: Buffer): Promise<void>;
|
||||
finalizeFileUpload(fileId: number): Promise<{ ok: true; size: string }>;
|
||||
deleteFile(fileId: number): Promise<boolean>;
|
||||
updateFile(
|
||||
id: number,
|
||||
updates: Partial<Pick<CloudFile, "name" | "mimeType" | "folderId">>
|
||||
): Promise<CloudFile | null>;
|
||||
renameFile(id: number, name: string): Promise<CloudFile | null>;
|
||||
countFilesInFolder(folderId: number | null): Promise<number>;
|
||||
countFiles(filter?: {
|
||||
userId?: number;
|
||||
nameContains?: string | null;
|
||||
mimeType?: string | null;
|
||||
}): Promise<number>;
|
||||
|
||||
// Search
|
||||
searchFolders(
|
||||
q: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
parentId?: number | null
|
||||
): Promise<{ data: CloudFolder[]; total: number }>;
|
||||
searchFiles(
|
||||
q: string,
|
||||
type: string | undefined,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ data: CloudFile[]; total: number }>;
|
||||
|
||||
// Streaming
|
||||
streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise<void>;
|
||||
}
|
||||
|
||||
/* ------------------------------- Implementation ------------------------------- */
|
||||
export const cloudStorageStorage: IStorage = {
|
||||
// --- FOLDERS ---
|
||||
async getFolder(id: number) {
|
||||
const folder = await db.cloudFolder.findUnique({
|
||||
where: { id },
|
||||
include: { files: false },
|
||||
});
|
||||
return (folder as unknown as CloudFolder) ?? null;
|
||||
},
|
||||
|
||||
async listRecentFolders(limit = 50, offset = 0, parentId?: number | null) {
|
||||
const where: any = {};
|
||||
|
||||
// parentId === undefined → no filter (global recent)
|
||||
// parentId === null → top-level folders (parent IS NULL)
|
||||
// parentId === number → children of that folder
|
||||
if (parentId !== undefined) {
|
||||
where.parentId = parentId;
|
||||
}
|
||||
|
||||
const folders = await db.cloudFolder.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return folders as unknown as CloudFolder[];
|
||||
},
|
||||
|
||||
async countFoldersByParent(parentId: number | null = null) {
|
||||
return db.cloudFolder.count({ where: { parentId } });
|
||||
},
|
||||
|
||||
async createFolder(
|
||||
userId: number,
|
||||
name: string,
|
||||
parentId: number | null = null
|
||||
) {
|
||||
const created = await db.cloudFolder.create({
|
||||
data: { userId, name, parentId },
|
||||
});
|
||||
// mark parent(s) as updated
|
||||
await updateFolderTimestampsRecursively(parentId);
|
||||
return created as unknown as CloudFolder;
|
||||
},
|
||||
|
||||
async updateFolder(
|
||||
id: number,
|
||||
updates: Partial<{ name?: string; parentId?: number | null }>
|
||||
) {
|
||||
try {
|
||||
const updated = await db.cloudFolder.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
if (updates.parentId !== undefined) {
|
||||
await updateFolderTimestampsRecursively(updates.parentId ?? null);
|
||||
} else {
|
||||
// touch this folder's parent (to mark modification)
|
||||
const f = await db.cloudFolder.findUnique({
|
||||
where: { id },
|
||||
select: { parentId: true },
|
||||
});
|
||||
await updateFolderTimestampsRecursively(f?.parentId ?? null);
|
||||
}
|
||||
return updated as unknown as CloudFolder;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFolder(id: number) {
|
||||
try {
|
||||
const folder = await db.cloudFolder.findUnique({
|
||||
where: { id },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const parentId = folder?.parentId ?? null;
|
||||
await db.cloudFolder.delete({ where: { id } });
|
||||
await updateFolderTimestampsRecursively(parentId);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "P2025") return false;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async countFolders(filter?: {
|
||||
userId?: number;
|
||||
nameContains?: string | null;
|
||||
}) {
|
||||
const where: any = {};
|
||||
if (filter?.userId) where.userId = filter.userId;
|
||||
if (filter?.nameContains)
|
||||
where.name = { contains: filter.nameContains, mode: "insensitive" };
|
||||
return db.cloudFolder.count({ where });
|
||||
},
|
||||
|
||||
// --- FILES ---
|
||||
async getFile(id: number) {
|
||||
const file = await db.cloudFile.findUnique({
|
||||
where: { id },
|
||||
include: { chunks: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
return (file as unknown as CloudFile) ?? null;
|
||||
},
|
||||
|
||||
async listFilesInFolder(
|
||||
folderId: number | null = null,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
) {
|
||||
const files = await db.cloudFile.findMany({
|
||||
where: { folderId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
folderId: true,
|
||||
isComplete: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
return files.map(serializeFile) as unknown as CloudFile[];
|
||||
},
|
||||
|
||||
async initializeFileUpload(
|
||||
userId: number,
|
||||
name: string,
|
||||
mimeType: string | null = null,
|
||||
expectedSize: bigint | null = null,
|
||||
totalChunks: number | null = null,
|
||||
folderId: number | null = null
|
||||
) {
|
||||
const created = await db.cloudFile.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
mimeType,
|
||||
fileSize: expectedSize ?? BigInt(0),
|
||||
folderId,
|
||||
totalChunks,
|
||||
isComplete: false,
|
||||
},
|
||||
});
|
||||
await updateFolderTimestampsRecursively(folderId);
|
||||
return serializeFile(created) as unknown as CloudFile;
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
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 folderId = (updated as any)?.folderId ?? null;
|
||||
await updateFolderTimestampsRecursively(folderId);
|
||||
|
||||
return { ok: true, size: BigInt(total).toString() };
|
||||
},
|
||||
|
||||
async deleteFile(fileId: number) {
|
||||
try {
|
||||
const file = await db.cloudFile.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { folderId: true },
|
||||
});
|
||||
if (!file) return false;
|
||||
const folderId = file.folderId ?? null;
|
||||
await db.cloudFile.delete({ where: { id: fileId } });
|
||||
await updateFolderTimestampsRecursively(folderId);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "P2025") return false;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async updateFile(
|
||||
id: number,
|
||||
updates: Partial<Pick<CloudFile, "name" | "mimeType" | "folderId">>
|
||||
) {
|
||||
try {
|
||||
let prevFolderId: number | null = null;
|
||||
if (updates.folderId !== undefined) {
|
||||
const f = await db.cloudFile.findUnique({
|
||||
where: { id },
|
||||
select: { folderId: true },
|
||||
});
|
||||
prevFolderId = f?.folderId ?? null;
|
||||
}
|
||||
|
||||
const updated = await db.cloudFile.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
|
||||
// touch affected folders
|
||||
if (updates.folderId !== undefined) {
|
||||
await updateFolderTimestampsRecursively(updates.folderId ?? null);
|
||||
if (
|
||||
prevFolderId != null &&
|
||||
prevFolderId !== (updates.folderId ?? null)
|
||||
) {
|
||||
await updateFolderTimestampsRecursively(prevFolderId);
|
||||
}
|
||||
} else {
|
||||
const f = await db.cloudFile.findUnique({
|
||||
where: { id },
|
||||
select: { folderId: true },
|
||||
});
|
||||
await updateFolderTimestampsRecursively(f?.folderId ?? null);
|
||||
}
|
||||
|
||||
return serializeFile(updated) as unknown as CloudFile;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async renameFile(id: number, name: string) {
|
||||
try {
|
||||
const updated = await db.cloudFile.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
});
|
||||
const f = await db.cloudFile.findUnique({
|
||||
where: { id },
|
||||
select: { folderId: true },
|
||||
});
|
||||
await updateFolderTimestampsRecursively(f?.folderId ?? null);
|
||||
return serializeFile(updated) as unknown as CloudFile;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async countFilesInFolder(folderId: number | null) {
|
||||
return db.cloudFile.count({ where: { folderId } });
|
||||
},
|
||||
|
||||
async countFiles(filter?: {
|
||||
userId?: number;
|
||||
nameContains?: string | null;
|
||||
mimeType?: string | null;
|
||||
}) {
|
||||
const where: any = {};
|
||||
if (filter?.userId) where.userId = filter.userId;
|
||||
if (filter?.nameContains)
|
||||
where.name = { contains: filter.nameContains, mode: "insensitive" };
|
||||
if (filter?.mimeType)
|
||||
where.mimeType = { startsWith: filter.mimeType, mode: "insensitive" };
|
||||
return db.cloudFile.count({ where });
|
||||
},
|
||||
|
||||
// --- SEARCH ---
|
||||
async searchFolders(
|
||||
q: string,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
parentId?: number | null
|
||||
) {
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
name: { contains: q, mode: "insensitive" },
|
||||
};
|
||||
|
||||
// If parentId is explicitly provided:
|
||||
// - parentId === null -> top-level folders (parent IS NULL)
|
||||
// - parentId === number -> children of that folder
|
||||
// If parentId is undefined -> search across all folders (no parent filter)
|
||||
if (parentId !== undefined) {
|
||||
where.parentId = parentId;
|
||||
}
|
||||
|
||||
const [folders, total] = await Promise.all([
|
||||
db.cloudFolder.findMany({
|
||||
where,
|
||||
orderBy: { name: "asc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
db.cloudFolder.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
return { data: folders as unknown as CloudFolder[], total };
|
||||
},
|
||||
|
||||
async searchFiles(
|
||||
q: string,
|
||||
type: string | undefined,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
) {
|
||||
const where: any = {};
|
||||
if (q) where.name = { contains: q, mode: "insensitive" };
|
||||
if (type) {
|
||||
if (!type.includes("/"))
|
||||
where.mimeType = { startsWith: `${type}/`, mode: "insensitive" };
|
||||
else where.mimeType = { startsWith: type, mode: "insensitive" };
|
||||
}
|
||||
|
||||
const [files, total] = await Promise.all([
|
||||
db.cloudFile.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
mimeType: true,
|
||||
fileSize: true,
|
||||
folderId: true,
|
||||
isComplete: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.cloudFile.count({ where }),
|
||||
]);
|
||||
|
||||
return { data: files.map(serializeFile) as unknown as CloudFile[], total };
|
||||
},
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default cloudStorageStorage;
|
||||
113
apps/Backend/src/storage/database-backup-storage.ts
Executable file
113
apps/Backend/src/storage/database-backup-storage.ts
Executable file
@@ -0,0 +1,113 @@
|
||||
import { DatabaseBackup, BackupDestination } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
// Database Backup methods
|
||||
createBackup(userId: number): Promise<DatabaseBackup>;
|
||||
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
||||
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
||||
deleteBackups(userId: number): Promise<number>; // clears all for user
|
||||
|
||||
// ==============================
|
||||
// Backup Destination methods
|
||||
// ==============================
|
||||
createBackupDestination(
|
||||
userId: number,
|
||||
path: string
|
||||
): Promise<BackupDestination>;
|
||||
|
||||
getActiveBackupDestination(
|
||||
userId: number
|
||||
): Promise<BackupDestination | null>;
|
||||
|
||||
getAllBackupDestination(
|
||||
userId: number
|
||||
): Promise<BackupDestination[]>;
|
||||
|
||||
updateBackupDestination(
|
||||
id: number,
|
||||
userId: number,
|
||||
path: string
|
||||
): Promise<BackupDestination>;
|
||||
|
||||
deleteBackupDestination(
|
||||
id: number,
|
||||
userId: number
|
||||
): Promise<BackupDestination>;
|
||||
}
|
||||
|
||||
export const databaseBackupStorage: IStorage = {
|
||||
// ==============================
|
||||
// Database Backup methods
|
||||
// ==============================
|
||||
async createBackup(userId) {
|
||||
return await db.databaseBackup.create({ data: { userId } });
|
||||
},
|
||||
|
||||
async getLastBackup(userId) {
|
||||
return await db.databaseBackup.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async getBackups(userId, limit = 10) {
|
||||
return await db.databaseBackup.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteBackups(userId) {
|
||||
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
||||
return result.count;
|
||||
},
|
||||
|
||||
// ==============================
|
||||
// Backup Destination methods
|
||||
// ==============================
|
||||
async createBackupDestination(userId, path) {
|
||||
// deactivate existing destination
|
||||
await db.backupDestination.updateMany({
|
||||
where: { userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return db.backupDestination.create({
|
||||
data: { userId, path },
|
||||
});
|
||||
},
|
||||
|
||||
async getActiveBackupDestination(userId) {
|
||||
return db.backupDestination.findFirst({
|
||||
where: { userId, isActive: true },
|
||||
});
|
||||
},
|
||||
|
||||
async getAllBackupDestination(userId) {
|
||||
return db.backupDestination.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async updateBackupDestination(id, userId, path) {
|
||||
// optional: make this one active
|
||||
await db.backupDestination.updateMany({
|
||||
where: { userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return db.backupDestination.update({
|
||||
where: { id, userId },
|
||||
data: { path, isActive: true },
|
||||
});
|
||||
},
|
||||
|
||||
async deleteBackupDestination(id, userId) {
|
||||
return db.backupDestination.delete({
|
||||
where: { id, userId },
|
||||
});
|
||||
},
|
||||
};
|
||||
140
apps/Backend/src/storage/export-payments-reports-storage.ts
Executable file
140
apps/Backend/src/storage/export-payments-reports-storage.ts
Executable file
@@ -0,0 +1,140 @@
|
||||
import { storage } from "../storage";
|
||||
import { getPatientFinancialRowsFn } from "./patients-storage";
|
||||
import { GetPatientBalancesResult } from "@repo/db/types";
|
||||
|
||||
type PatientSummaryRow = {
|
||||
patientId: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
currentBalance: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Page through storage.getPatientsWithBalances to return the full list (not paginated).
|
||||
* Uses the same filters (from/to) as the existing queries.
|
||||
*/
|
||||
export async function fetchAllPatientsWithBalances(
|
||||
from?: Date | null,
|
||||
to?: Date | null,
|
||||
pageSize = 500
|
||||
): Promise<PatientSummaryRow[]> {
|
||||
const all: PatientSummaryRow[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page: GetPatientBalancesResult =
|
||||
await storage.getPatientsWithBalances(pageSize, cursor, from, to);
|
||||
if (!page) break;
|
||||
if (Array.isArray(page.balances) && page.balances.length) {
|
||||
for (const b of page.balances) {
|
||||
all.push({
|
||||
patientId: Number(b.patientId),
|
||||
firstName: b.firstName ?? null,
|
||||
lastName: b.lastName ?? null,
|
||||
currentBalance: Number(b.currentBalance ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!page.hasMore || !page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page through storage.getPatientsBalancesByDoctor to return full patient list for the staff.
|
||||
*/
|
||||
export async function fetchAllPatientsForDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null,
|
||||
pageSize = 500
|
||||
): Promise<PatientSummaryRow[]> {
|
||||
const all: PatientSummaryRow[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page: GetPatientBalancesResult =
|
||||
await storage.getPatientsBalancesByDoctor(
|
||||
staffId,
|
||||
pageSize,
|
||||
cursor,
|
||||
from,
|
||||
to
|
||||
);
|
||||
if (!page) break;
|
||||
if (Array.isArray(page.balances) && page.balances.length) {
|
||||
for (const b of page.balances) {
|
||||
all.push({
|
||||
patientId: Number(b.patientId),
|
||||
firstName: b.firstName ?? null,
|
||||
lastName: b.lastName ?? null,
|
||||
currentBalance: Number(b.currentBalance ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!page.hasMore || !page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each patient, call the existing function to fetch full financial rows.
|
||||
* This uses your existing getPatientFinancialRowsFn which returns { rows, totalCount }.
|
||||
*
|
||||
* The function returns an array of:
|
||||
* { patientId, firstName, lastName, currentBalance, financialRows: Array<{ type, date, procedureCode, billed, paid, adjusted, totalDue, status }> }
|
||||
*/
|
||||
export async function buildExportRowsForPatients(
|
||||
patients: PatientSummaryRow[],
|
||||
perPatientLimit = 5000
|
||||
) {
|
||||
const out: Array<any> = [];
|
||||
|
||||
for (const p of patients) {
|
||||
const patientId = Number(p.patientId);
|
||||
const { rows } = await getPatientFinancialRowsFn(
|
||||
patientId,
|
||||
perPatientLimit,
|
||||
0
|
||||
); // returns rows array similarly to your earlier code
|
||||
|
||||
const frs = rows.flatMap((r: any) => {
|
||||
const svc = r.service_lines ?? [];
|
||||
if (svc.length > 0) {
|
||||
return svc.map((sl: any) => ({
|
||||
type: r.type,
|
||||
date: r.date ? new Date(r.date).toLocaleDateString() : "",
|
||||
procedureCode: String(sl.procedureCode ?? "-"),
|
||||
billed: Number(sl.totalBilled ?? 0),
|
||||
paid: Number(sl.totalPaid ?? 0),
|
||||
adjusted: Number(sl.totalAdjusted ?? 0),
|
||||
totalDue: Number(sl.totalDue ?? 0),
|
||||
status: sl.status ?? r.status ?? "",
|
||||
}));
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
type: r.type,
|
||||
date: r.date ? new Date(r.date).toLocaleDateString() : "",
|
||||
procedureCode: "-",
|
||||
billed: Number(r.total_billed ?? r.totalBilled ?? 0),
|
||||
paid: Number(r.total_paid ?? r.totalPaid ?? 0),
|
||||
adjusted: Number(r.total_adjusted ?? r.totalAdjusted ?? 0),
|
||||
totalDue: Number(r.total_due ?? r.totalDue ?? 0),
|
||||
status: r.status ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
out.push({
|
||||
patientId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
currentBalance: Number(p.currentBalance ?? 0),
|
||||
financialRows: frs,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
218
apps/Backend/src/storage/general-pdf-storage.ts
Executable file
218
apps/Backend/src/storage/general-pdf-storage.ts
Executable file
@@ -0,0 +1,218 @@
|
||||
import { PdfFile, PdfGroup } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { PdfTitleKey } from "@repo/db/generated/prisma";
|
||||
|
||||
export interface IStorage {
|
||||
// General PDF Methods
|
||||
createPdfFile(
|
||||
groupId: number,
|
||||
filename: string,
|
||||
pdfData: Buffer
|
||||
): Promise<PdfFile>;
|
||||
getPdfFileById(id: number): Promise<PdfFile | undefined>;
|
||||
getPdfFilesByGroupId(
|
||||
groupId: number,
|
||||
opts?: { limit?: number; offset?: number; withGroup?: boolean }
|
||||
): Promise<PdfFile[] | { total: number; data: PdfFile[] }>;
|
||||
getRecentPdfFiles(limit: number, offset: number): Promise<PdfFile[]>;
|
||||
deletePdfFile(id: number): Promise<boolean>;
|
||||
updatePdfFile(
|
||||
id: number,
|
||||
updates: Partial<Pick<PdfFile, "filename" | "pdfData">>
|
||||
): Promise<PdfFile | undefined>;
|
||||
|
||||
// PDF Group management
|
||||
createPdfGroup(
|
||||
patientId: number,
|
||||
title: string,
|
||||
titleKey: PdfTitleKey
|
||||
): Promise<PdfGroup>;
|
||||
findPdfGroupByPatientTitleKey(
|
||||
patientId: number,
|
||||
titleKey: PdfTitleKey
|
||||
): Promise<PdfGroup | undefined>;
|
||||
getAllPdfGroups(): Promise<PdfGroup[]>;
|
||||
getPdfGroupById(id: number): Promise<PdfGroup | undefined>;
|
||||
getPdfGroupsByPatientId(patientId: number): Promise<PdfGroup[]>;
|
||||
updatePdfGroup(
|
||||
id: number,
|
||||
updates: Partial<Pick<PdfGroup, "title">>
|
||||
): Promise<PdfGroup | undefined>;
|
||||
deletePdfGroup(id: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const generalPdfStorage: IStorage = {
|
||||
// PDF Files
|
||||
async createPdfFile(groupId, filename, pdfData) {
|
||||
return db.pdfFile.create({
|
||||
data: {
|
||||
groupId,
|
||||
filename,
|
||||
pdfData,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async getAllPdfGroups(): Promise<PdfGroup[]> {
|
||||
return db.pdfGroup.findMany({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async getPdfFileById(id) {
|
||||
return (await db.pdfFile.findUnique({ where: { id } })) ?? undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* getPdfFilesByGroupId: supports
|
||||
* - getPdfFilesByGroupId(groupId) => Promise<PdfFile[]>
|
||||
* - getPdfFilesByGroupId(groupId, { limit, offset }) => Promise<{ total, data }>
|
||||
* - getPdfFilesByGroupId(groupId, { limit, offset, withGroup: true }) => Promise<{ total, data: PdfFileWithGroup[] }>
|
||||
*/
|
||||
async getPdfFilesByGroupId(groupId, opts) {
|
||||
// if pagination is requested (limit provided) return total + page
|
||||
const wantsPagination =
|
||||
!!opts &&
|
||||
(typeof opts.limit === "number" || typeof opts.offset === "number");
|
||||
|
||||
if (wantsPagination) {
|
||||
const limit = Math.min(Number(opts?.limit ?? 5), 1000);
|
||||
const offset = Number(opts?.offset ?? 0);
|
||||
|
||||
if (opts?.withGroup) {
|
||||
// return total + data with group included
|
||||
const [total, data] = await Promise.all([
|
||||
db.pdfFile.count({ where: { groupId } }),
|
||||
db.pdfFile.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: { group: true }, // only include
|
||||
}),
|
||||
]);
|
||||
|
||||
return { total, data };
|
||||
} else {
|
||||
// return total + data with limited fields via select
|
||||
const [total, data] = await Promise.all([
|
||||
db.pdfFile.count({ where: { groupId } }),
|
||||
db.pdfFile.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: { id: true, filename: true, uploadedAt: true }, // only select
|
||||
}),
|
||||
]);
|
||||
|
||||
// Note: selected shape won't have all PdfFile fields; cast if needed
|
||||
return { total, data: data as unknown as PdfFile[] };
|
||||
}
|
||||
}
|
||||
|
||||
// non-paginated: return all files (keep descending order)
|
||||
if (opts?.withGroup) {
|
||||
const all = await db.pdfFile.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
include: { group: true },
|
||||
});
|
||||
return all as PdfFile[];
|
||||
} else {
|
||||
const all = await db.pdfFile.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
// no select or include -> returns full PdfFile
|
||||
});
|
||||
return all as PdfFile[];
|
||||
}
|
||||
},
|
||||
|
||||
async getRecentPdfFiles(limit: number, offset: number): Promise<PdfFile[]> {
|
||||
return db.pdfFile.findMany({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
include: { group: true },
|
||||
});
|
||||
},
|
||||
|
||||
async updatePdfFile(id, updates) {
|
||||
try {
|
||||
return await db.pdfFile.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
async deletePdfFile(id) {
|
||||
try {
|
||||
await db.pdfFile.delete({ where: { id } });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------
|
||||
// PdfGroup CRUD
|
||||
// ----------------------
|
||||
|
||||
async createPdfGroup(patientId, title, titleKey) {
|
||||
return db.pdfGroup.create({
|
||||
data: {
|
||||
patientId,
|
||||
title,
|
||||
titleKey,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async findPdfGroupByPatientTitleKey(patientId, titleKey) {
|
||||
return (
|
||||
(await db.pdfGroup.findFirst({
|
||||
where: {
|
||||
patientId,
|
||||
titleKey,
|
||||
},
|
||||
})) ?? undefined
|
||||
);
|
||||
},
|
||||
|
||||
async getPdfGroupById(id) {
|
||||
return (await db.pdfGroup.findUnique({ where: { id } })) ?? undefined;
|
||||
},
|
||||
|
||||
async getPdfGroupsByPatientId(patientId) {
|
||||
return db.pdfGroup.findMany({
|
||||
where: { patientId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async updatePdfGroup(id, updates) {
|
||||
try {
|
||||
return await db.pdfGroup.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
async deletePdfGroup(id) {
|
||||
try {
|
||||
await db.pdfGroup.delete({ where: { id } });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
41
apps/Backend/src/storage/index.ts
Executable file
41
apps/Backend/src/storage/index.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
|
||||
|
||||
import { usersStorage } from './users-storage';
|
||||
import { patientsStorage } from './patients-storage';
|
||||
import { appointmentsStorage } from './appointments-storage';
|
||||
import { appointmentProceduresStorage } from './appointment-procedures-storage';
|
||||
import { staffStorage } from './staff-storage';
|
||||
import { npiProviderStorage } from './npi-providers-storage';
|
||||
import { claimsStorage } from './claims-storage';
|
||||
import { insuranceCredsStorage } from './insurance-creds-storage';
|
||||
import { generalPdfStorage } from './general-pdf-storage';
|
||||
import { paymentsStorage } from './payments-storage';
|
||||
import { databaseBackupStorage } from './database-backup-storage';
|
||||
import { notificationsStorage } from './notifications-storage';
|
||||
import { cloudStorageStorage } from './cloudStorage-storage';
|
||||
import { paymentsReportsStorage } from './payments-reports-storage';
|
||||
import { patientDocumentsStorage } from './patientDocuments-storage';
|
||||
import * as exportPaymentsReportsStorage from "./export-payments-reports-storage";
|
||||
|
||||
|
||||
export const storage = {
|
||||
...usersStorage,
|
||||
...patientsStorage,
|
||||
...appointmentsStorage,
|
||||
...appointmentProceduresStorage,
|
||||
...staffStorage,
|
||||
...npiProviderStorage,
|
||||
...claimsStorage,
|
||||
...insuranceCredsStorage,
|
||||
...generalPdfStorage,
|
||||
...paymentsStorage,
|
||||
...databaseBackupStorage,
|
||||
...notificationsStorage,
|
||||
...cloudStorageStorage,
|
||||
...paymentsReportsStorage,
|
||||
...patientDocumentsStorage,
|
||||
...exportPaymentsReportsStorage,
|
||||
|
||||
};
|
||||
|
||||
export default storage;
|
||||
63
apps/Backend/src/storage/insurance-creds-storage.ts
Executable file
63
apps/Backend/src/storage/insurance-creds-storage.ts
Executable file
@@ -0,0 +1,63 @@
|
||||
import { InsertInsuranceCredential, InsuranceCredential } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
getInsuranceCredential(id: number): Promise<InsuranceCredential | null>;
|
||||
getInsuranceCredentialsByUser(userId: number): Promise<InsuranceCredential[]>;
|
||||
createInsuranceCredential(
|
||||
data: InsertInsuranceCredential
|
||||
): Promise<InsuranceCredential>;
|
||||
updateInsuranceCredential(
|
||||
id: number,
|
||||
updates: Partial<InsuranceCredential>
|
||||
): Promise<InsuranceCredential | null>;
|
||||
deleteInsuranceCredential(userId: number, id: number): Promise<boolean>;
|
||||
getInsuranceCredentialByUserAndSiteKey(
|
||||
userId: number,
|
||||
siteKey: string
|
||||
): Promise<InsuranceCredential | null>;
|
||||
}
|
||||
|
||||
export const insuranceCredsStorage: IStorage = {
|
||||
async getInsuranceCredential(id: number) {
|
||||
return await db.insuranceCredential.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
async getInsuranceCredentialsByUser(userId: number) {
|
||||
return await db.insuranceCredential.findMany({ where: { userId } });
|
||||
},
|
||||
|
||||
async createInsuranceCredential(data: InsertInsuranceCredential) {
|
||||
return await db.insuranceCredential.create({
|
||||
data: data as InsuranceCredential,
|
||||
});
|
||||
},
|
||||
|
||||
async updateInsuranceCredential(
|
||||
id: number,
|
||||
updates: Partial<InsuranceCredential>
|
||||
) {
|
||||
return await db.insuranceCredential.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteInsuranceCredential(userId: number, id: number) {
|
||||
try {
|
||||
await db.insuranceCredential.delete({ where: { userId, id } });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async getInsuranceCredentialByUserAndSiteKey(
|
||||
userId: number,
|
||||
siteKey: string
|
||||
): Promise<InsuranceCredential | null> {
|
||||
return await db.insuranceCredential.findFirst({
|
||||
where: { userId, siteKey },
|
||||
});
|
||||
},
|
||||
};
|
||||
80
apps/Backend/src/storage/notifications-storage.ts
Executable file
80
apps/Backend/src/storage/notifications-storage.ts
Executable file
@@ -0,0 +1,80 @@
|
||||
import { Notification, NotificationTypes } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
// Notification methods
|
||||
createNotification(
|
||||
userId: number,
|
||||
type: NotificationTypes,
|
||||
message: string
|
||||
): Promise<Notification>;
|
||||
getNotifications(
|
||||
userId: number,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<Notification[]>;
|
||||
markNotificationRead(
|
||||
userId: number,
|
||||
notificationId: number
|
||||
): Promise<boolean>;
|
||||
markAllNotificationsRead(userId: number): Promise<number>;
|
||||
deleteNotificationsByType(
|
||||
userId: number,
|
||||
type: NotificationTypes
|
||||
): Promise<number>;
|
||||
deleteAllNotifications(userId: number): Promise<number>;
|
||||
}
|
||||
|
||||
export const notificationsStorage: IStorage = {
|
||||
// ==============================
|
||||
// Notification methods
|
||||
// ==============================
|
||||
async createNotification(userId, type, message) {
|
||||
return await db.notification.create({
|
||||
data: { userId, type, message },
|
||||
});
|
||||
},
|
||||
|
||||
async getNotifications(
|
||||
userId: number,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<Notification[]> {
|
||||
return await db.notification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
},
|
||||
|
||||
async markNotificationRead(userId, notificationId) {
|
||||
const result = await db.notification.updateMany({
|
||||
where: { id: notificationId, userId },
|
||||
data: { read: true },
|
||||
});
|
||||
return result.count > 0;
|
||||
},
|
||||
|
||||
async markAllNotificationsRead(userId) {
|
||||
const result = await db.notification.updateMany({
|
||||
where: { userId },
|
||||
data: { read: true },
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async deleteNotificationsByType(userId, type) {
|
||||
const result = await db.notification.deleteMany({
|
||||
where: { userId, type },
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async deleteAllNotifications(userId: number): Promise<number> {
|
||||
const result = await db.notification.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
return result.count;
|
||||
},
|
||||
};
|
||||
50
apps/Backend/src/storage/npi-providers-storage.ts
Executable file
50
apps/Backend/src/storage/npi-providers-storage.ts
Executable file
@@ -0,0 +1,50 @@
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { InsertNpiProvider, NpiProvider } from "@repo/db/types";
|
||||
|
||||
export interface INpiProviderStorage {
|
||||
getNpiProvider(id: number): Promise<NpiProvider | null>;
|
||||
getNpiProvidersByUser(userId: number): Promise<NpiProvider[]>;
|
||||
createNpiProvider(data: InsertNpiProvider): Promise<NpiProvider>;
|
||||
updateNpiProvider(
|
||||
id: number,
|
||||
updates: Partial<NpiProvider>,
|
||||
): Promise<NpiProvider | null>;
|
||||
deleteNpiProvider(userId: number, id: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const npiProviderStorage: INpiProviderStorage = {
|
||||
async getNpiProvider(id: number) {
|
||||
return db.npiProvider.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
async getNpiProvidersByUser(userId: number) {
|
||||
return db.npiProvider.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async createNpiProvider(data: InsertNpiProvider) {
|
||||
return db.npiProvider.create({
|
||||
data: data as NpiProvider,
|
||||
});
|
||||
},
|
||||
|
||||
async updateNpiProvider(id: number, updates: Partial<NpiProvider>) {
|
||||
return db.npiProvider.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteNpiProvider(userId: number, id: number) {
|
||||
try {
|
||||
await db.npiProvider.delete({
|
||||
where: { id, userId },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
179
apps/Backend/src/storage/patientDocuments-storage.ts
Executable file
179
apps/Backend/src/storage/patientDocuments-storage.ts
Executable file
@@ -0,0 +1,179 @@
|
||||
import { PatientDocument, CreatePatientDocument } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads", "patient-documents");
|
||||
|
||||
// Get local file path from URL
|
||||
const getLocalFilePath = (fileUrl: string): string => {
|
||||
const filename = fileUrl.split('/').pop() || '';
|
||||
return path.join(UPLOAD_DIR, filename);
|
||||
};
|
||||
|
||||
// Get the base URL for serving files
|
||||
const getBaseUrl = (): string => {
|
||||
// For development, use localhost instead of 0.0.0.0
|
||||
const host = process.env.HOST === '0.0.0.0' ? 'localhost' : (process.env.HOST || 'localhost');
|
||||
const port = process.env.PORT || '5000';
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${host}:${port}`;
|
||||
};
|
||||
|
||||
// Ensure upload directory exists
|
||||
const ensureUploadDir = async () => {
|
||||
try {
|
||||
await fs.access(UPLOAD_DIR);
|
||||
} catch {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Generate unique filename
|
||||
const generateUniqueFilename = (originalName: string): string => {
|
||||
const ext = path.extname(originalName);
|
||||
const name = path.basename(originalName, ext);
|
||||
const timestamp = Date.now();
|
||||
const random = randomBytes(4).toString("hex");
|
||||
return `${name}-${timestamp}-${random}${ext}`;
|
||||
};
|
||||
|
||||
export const patientDocumentsStorage = {
|
||||
// Create a new patient document
|
||||
createPatientDocument: async (
|
||||
patientId: number,
|
||||
filename: string,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
fileSize: number,
|
||||
buffer: Buffer
|
||||
): Promise<PatientDocument> => {
|
||||
await ensureUploadDir();
|
||||
|
||||
const uniqueFilename = generateUniqueFilename(filename);
|
||||
const localFilePath = path.join(UPLOAD_DIR, uniqueFilename);
|
||||
|
||||
// Save file to disk
|
||||
await fs.writeFile(localFilePath, buffer);
|
||||
|
||||
// Create the full URL for accessing the file using the uploads directory structure
|
||||
const fileUrl = `${getBaseUrl()}/uploads/patient-documents/${uniqueFilename}`;
|
||||
|
||||
// Create database record with full URL
|
||||
const document = await db.patientDocument.create({
|
||||
data: {
|
||||
patientId,
|
||||
filename: uniqueFilename,
|
||||
originalName,
|
||||
mimeType,
|
||||
fileSize: BigInt(fileSize),
|
||||
filePath: fileUrl, // Store the full URL instead of local path
|
||||
},
|
||||
});
|
||||
|
||||
return document;
|
||||
},
|
||||
|
||||
// Get all documents for a patient
|
||||
getDocumentsByPatientId: async (patientId: number): Promise<PatientDocument[]> => {
|
||||
return await db.patientDocument.findMany({
|
||||
where: { patientId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
// Get a specific document by ID
|
||||
getDocumentById: async (id: number): Promise<PatientDocument | null> => {
|
||||
return await db.patientDocument.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
patient: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Get document file content
|
||||
getDocumentFile: async (id: number): Promise<{ buffer: Buffer; document: PatientDocument } | null> => {
|
||||
const document = await db.patientDocument.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get local file path from the stored URL
|
||||
const localFilePath = getLocalFilePath(document.filePath);
|
||||
const buffer = await fs.readFile(localFilePath);
|
||||
return { buffer, document };
|
||||
} catch (error) {
|
||||
console.error("Error reading file:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a document
|
||||
deleteDocument: async (id: number): Promise<boolean> => {
|
||||
const document = await db.patientDocument.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get local file path from the stored URL
|
||||
const localFilePath = getLocalFilePath(document.filePath);
|
||||
|
||||
// Delete file from disk
|
||||
await fs.unlink(localFilePath);
|
||||
|
||||
// Delete database record
|
||||
await db.patientDocument.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting document:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Update document metadata
|
||||
updateDocument: async (id: number, data: Partial<CreatePatientDocument>): Promise<PatientDocument | null> => {
|
||||
return await db.patientDocument.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
// Get documents with pagination
|
||||
getDocumentsByPatientIdPaginated: async (
|
||||
patientId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ documents: PatientDocument[]; total: number }> => {
|
||||
const [documents, total] = await Promise.all([
|
||||
db.patientDocument.findMany({
|
||||
where: { patientId },
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
db.patientDocument.count({
|
||||
where: { patientId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { documents, total };
|
||||
},
|
||||
};
|
||||
287
apps/Backend/src/storage/patients-storage.ts
Executable file
287
apps/Backend/src/storage/patients-storage.ts
Executable file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
FinancialRow,
|
||||
InsertPatient,
|
||||
Patient,
|
||||
UpdatePatient,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
// Patient methods
|
||||
getPatient(id: number): Promise<Patient | undefined>;
|
||||
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
|
||||
getPatientsByUserId(userId: number): Promise<Patient[]>;
|
||||
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
|
||||
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
||||
createPatient(patient: InsertPatient): Promise<Patient>;
|
||||
updatePatient(id: number, patient: UpdatePatient): Promise<Patient>;
|
||||
deletePatient(id: number): Promise<void>;
|
||||
searchPatients(args: {
|
||||
filters: any;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<
|
||||
{
|
||||
id: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
gender: string | null;
|
||||
dateOfBirth: Date;
|
||||
insuranceId: string | null;
|
||||
insuranceProvider: string | null;
|
||||
status: string;
|
||||
}[]
|
||||
>;
|
||||
getTotalPatientCount(): Promise<number>;
|
||||
countPatients(filters: any): Promise<number>; // optional but useful
|
||||
getPatientFinancialRows(
|
||||
patientId: number,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<{ rows: any[]; totalCount: number }>;
|
||||
}
|
||||
|
||||
export const patientsStorage: IStorage = {
|
||||
// Patient methods
|
||||
async getPatient(id: number): Promise<Patient | undefined> {
|
||||
const patient = await db.patient.findUnique({ where: { id } });
|
||||
return patient ?? undefined;
|
||||
},
|
||||
|
||||
async getPatientsByUserId(userId: number): Promise<Patient[]> {
|
||||
return await db.patient.findMany({ where: { userId } });
|
||||
},
|
||||
|
||||
async getPatientByInsuranceId(insuranceId: string): Promise<Patient | null> {
|
||||
return db.patient.findFirst({
|
||||
where: { insuranceId },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
|
||||
return db.patient.findMany({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async getPatientsByIds(ids: number[]): Promise<Patient[]> {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
return db.patient.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
insuranceId: true,
|
||||
insuranceProvider: true,
|
||||
status: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createPatient(patient: InsertPatient): Promise<Patient> {
|
||||
return await db.patient.create({ data: patient as Patient });
|
||||
},
|
||||
|
||||
async updatePatient(id: number, updateData: UpdatePatient): Promise<Patient> {
|
||||
try {
|
||||
return await db.patient.update({
|
||||
where: { id },
|
||||
data: updateData as Patient,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Patient with ID ${id} not found`);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePatient(id: number): Promise<void> {
|
||||
try {
|
||||
await db.patient.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
console.error("Error deleting patient:", err);
|
||||
throw new Error(`Failed to delete patient: ${err}`);
|
||||
}
|
||||
},
|
||||
|
||||
async searchPatients({
|
||||
filters,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
filters: any;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) {
|
||||
return db.patient.findMany({
|
||||
where: filters,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
phone: true,
|
||||
gender: true,
|
||||
dateOfBirth: true,
|
||||
insuranceId: true,
|
||||
insuranceProvider: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async getTotalPatientCount(): Promise<number> {
|
||||
return db.patient.count();
|
||||
},
|
||||
|
||||
async countPatients(filters: any) {
|
||||
return db.patient.count({ where: filters });
|
||||
},
|
||||
|
||||
async getPatientFinancialRows(patientId: number, limit = 50, offset = 0) {
|
||||
return getPatientFinancialRowsFn(patientId, limit, offset);
|
||||
},
|
||||
};
|
||||
|
||||
export const getPatientFinancialRowsFn = async (
|
||||
patientId: number,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<{ rows: FinancialRow[]; totalCount: number }> => {
|
||||
try {
|
||||
// Count claims and orphan payments
|
||||
const [[{ count_claims }], [{ count_orphan_payments }]] =
|
||||
(await Promise.all([
|
||||
db.$queryRaw`SELECT COUNT(1) AS count_claims FROM "Claim" c WHERE c."patientId" = ${patientId}`,
|
||||
db.$queryRaw`SELECT COUNT(1) AS count_orphan_payments FROM "Payment" p WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL`,
|
||||
])) as any;
|
||||
|
||||
const totalCount =
|
||||
Number(count_claims ?? 0) + Number(count_orphan_payments ?? 0);
|
||||
|
||||
const rawRows = (await db.$queryRaw`
|
||||
WITH claim_rows AS (
|
||||
SELECT
|
||||
'CLAIM'::text AS type,
|
||||
c.id,
|
||||
COALESCE(c."serviceDate", c."createdAt")::timestamptz AS date,
|
||||
c."createdAt"::timestamptz AS created_at,
|
||||
c.status::text AS status,
|
||||
COALESCE(sum(sl."totalBilled")::numeric::text, '0') AS total_billed,
|
||||
COALESCE(sum(sl."totalPaid")::numeric::text, '0') AS total_paid,
|
||||
COALESCE(sum(sl."totalAdjusted")::numeric::text, '0') AS total_adjusted,
|
||||
COALESCE(sum(sl."totalDue")::numeric::text, '0') AS total_due,
|
||||
(
|
||||
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = c."patientId" LIMIT 1
|
||||
) AS patient_name,
|
||||
|
||||
-- linked_payment_id (NULL if none). Schema has unique Payment.claimId so LIMIT 1 is safe.
|
||||
(
|
||||
SELECT p2.id FROM "Payment" p2 WHERE p2."claimId" = c.id LIMIT 1
|
||||
) AS linked_payment_id,
|
||||
|
||||
(
|
||||
SELECT coalesce(json_agg(
|
||||
json_build_object(
|
||||
'id', sl2.id,
|
||||
'procedureCode', sl2."procedureCode",
|
||||
'procedureDate', sl2."procedureDate",
|
||||
'toothNumber', sl2."toothNumber",
|
||||
'toothSurface', sl2."toothSurface",
|
||||
'totalBilled', sl2."totalBilled",
|
||||
'totalPaid', sl2."totalPaid",
|
||||
'totalAdjusted', sl2."totalAdjusted",
|
||||
'totalDue', sl2."totalDue",
|
||||
'status', sl2.status
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM "ServiceLine" sl2 WHERE sl2."claimId" = c.id
|
||||
) AS service_lines
|
||||
FROM "Claim" c
|
||||
LEFT JOIN "ServiceLine" sl ON sl."claimId" = c.id
|
||||
WHERE c."patientId" = ${patientId}
|
||||
GROUP BY c.id
|
||||
),
|
||||
orphan_payment_rows AS (
|
||||
SELECT
|
||||
'PAYMENT'::text AS type,
|
||||
p.id,
|
||||
p."createdAt"::timestamptz AS date,
|
||||
p."createdAt"::timestamptz AS created_at,
|
||||
p.status::text AS status,
|
||||
p."totalBilled"::numeric::text AS total_billed,
|
||||
p."totalPaid"::numeric::text AS total_paid,
|
||||
p."totalAdjusted"::numeric::text AS total_adjusted,
|
||||
p."totalDue"::numeric::text AS total_due,
|
||||
(
|
||||
SELECT (pat."firstName" || ' ' || pat."lastName") FROM "Patient" pat WHERE pat.id = p."patientId" LIMIT 1
|
||||
) AS patient_name,
|
||||
|
||||
-- this payment's id is the linked_payment_id
|
||||
p.id AS linked_payment_id,
|
||||
|
||||
(
|
||||
SELECT coalesce(json_agg(
|
||||
json_build_object(
|
||||
'id', sl3.id,
|
||||
'procedureCode', sl3."procedureCode",
|
||||
'procedureDate', sl3."procedureDate",
|
||||
'toothNumber', sl3."toothNumber",
|
||||
'toothSurface', sl3."toothSurface",
|
||||
'totalBilled', sl3."totalBilled",
|
||||
'totalPaid', sl3."totalPaid",
|
||||
'totalAdjusted', sl3."totalAdjusted",
|
||||
'totalDue', sl3."totalDue",
|
||||
'status', sl3.status
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM "ServiceLine" sl3 WHERE sl3."paymentId" = p.id
|
||||
) AS service_lines
|
||||
FROM "Payment" p
|
||||
WHERE p."patientId" = ${patientId} AND p."claimId" IS NULL
|
||||
)
|
||||
SELECT type, id, date, created_at, status, total_billed, total_paid, total_adjusted, total_due, patient_name, linked_payment_id, service_lines
|
||||
FROM (
|
||||
SELECT * FROM claim_rows
|
||||
UNION ALL
|
||||
SELECT * FROM orphan_payment_rows
|
||||
) t
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`) as any[];
|
||||
|
||||
// map to expected JS shape; convert totals to numbers
|
||||
const rows: FinancialRow[] = rawRows.map((r: any) => ({
|
||||
type: r.type,
|
||||
id: Number(r.id),
|
||||
date: r.date ? r.date.toString() : null,
|
||||
createdAt: r.created_at ? r.created_at.toString() : null,
|
||||
status: r.status ?? null,
|
||||
total_billed: Number(r.total_billed ?? 0),
|
||||
total_paid: Number(r.total_paid ?? 0),
|
||||
total_adjusted: Number(r.total_adjusted ?? 0),
|
||||
total_due: Number(r.total_due ?? 0),
|
||||
patient_name: r.patient_name ?? null,
|
||||
service_lines: r.service_lines ?? [],
|
||||
linked_payment_id: r.linked_payment_id
|
||||
? Number(r.linked_payment_id)
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { rows, totalCount };
|
||||
} catch (err) {
|
||||
console.error("getPatientFinancialRowsFn error:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
858
apps/Backend/src/storage/payments-reports-storage.ts
Executable file
858
apps/Backend/src/storage/payments-reports-storage.ts
Executable file
@@ -0,0 +1,858 @@
|
||||
import { prisma } from "@repo/db/client";
|
||||
import {
|
||||
GetPatientBalancesResult,
|
||||
PatientBalanceRow,
|
||||
} from "../../../../packages/db/types/payments-reports-types";
|
||||
|
||||
export interface IPaymentsReportsStorage {
|
||||
// summary now returns an extra field patientsWithBalance
|
||||
getSummary(
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Cursor-based pagination:
|
||||
* - limit: page size
|
||||
* - cursorToken: base64(JSON) token for last-seen row (or null for first page)
|
||||
* - from/to: optional date range filter applied to Payment."createdAt"
|
||||
*/
|
||||
getPatientsWithBalances(
|
||||
limit: number,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* Returns the paginated patient balances for a specific staff (doctor).
|
||||
* Same semantics / columns / ordering / cursor behavior as the previous combined function.
|
||||
*
|
||||
* - staffId required
|
||||
* - limit: page size
|
||||
* - cursorToken: optional base64 cursor (must have been produced for same staffId)
|
||||
* - from/to: optional date range applied to Payment."createdAt"
|
||||
*/
|
||||
getPatientsBalancesByDoctor(
|
||||
staffId: number,
|
||||
limit: number,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<GetPatientBalancesResult>;
|
||||
|
||||
/**
|
||||
* Returns only the summary object for the given staff (doctor).
|
||||
* Same summary shape as getSummary(), but scoped to claims/payments associated with the given staffId.
|
||||
*/
|
||||
getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Return ISO literal for inclusive start-of-day (UTC midnight) */
|
||||
function isoStartOfDayLiteral(d?: Date | null): string | null {
|
||||
if (!d) return null;
|
||||
const dt = new Date(d);
|
||||
dt.setUTCHours(0, 0, 0, 0);
|
||||
return `'${dt.toISOString()}'`;
|
||||
}
|
||||
|
||||
/** Return ISO literal for exclusive next-day start (UTC midnight of the next day) */
|
||||
function isoStartOfNextDayLiteral(d?: Date | null): string | null {
|
||||
if (!d) return null;
|
||||
const dt = new Date(d);
|
||||
dt.setUTCHours(0, 0, 0, 0);
|
||||
dt.setUTCDate(dt.getUTCDate() + 1);
|
||||
return `'${dt.toISOString()}'`;
|
||||
}
|
||||
|
||||
/** Cursor helpers — base64(JSON) */
|
||||
/** Cursor format (backwards compatible):
|
||||
* { staffId?: number, lastPaymentDate: string | null, lastPatientId: number, lastPaymentMs?: number | null }
|
||||
*/
|
||||
function encodeCursor(obj: {
|
||||
staffId?: number;
|
||||
lastPaymentDate: string | null;
|
||||
lastPatientId: number;
|
||||
lastPaymentMs?: number | null;
|
||||
}) {
|
||||
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
||||
}
|
||||
|
||||
function decodeCursor(token?: string | null): {
|
||||
staffId?: number; // optional because older cursors might not include it
|
||||
lastPaymentDate: string | null;
|
||||
lastPatientId: number;
|
||||
lastPaymentMs?: number | null;
|
||||
} | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
"lastPaymentDate" in parsed &&
|
||||
"lastPatientId" in parsed
|
||||
) {
|
||||
return {
|
||||
staffId:
|
||||
"staffId" in parsed ? Number((parsed as any).staffId) : undefined,
|
||||
lastPaymentDate:
|
||||
(parsed as any).lastPaymentDate === null
|
||||
? null
|
||||
: String((parsed as any).lastPaymentDate),
|
||||
lastPatientId: Number((parsed as any).lastPatientId),
|
||||
lastPaymentMs:
|
||||
"lastPaymentMs" in parsed
|
||||
? parsed.lastPaymentMs === null
|
||||
? null
|
||||
: Number(parsed.lastPaymentMs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentsReportsStorage: IPaymentsReportsStorage = {
|
||||
async getSummary(from?: Date | null, to?: Date | null) {
|
||||
try {
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
||||
const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z'
|
||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
||||
|
||||
// totalPatients: distinct patients who had payments in the date range
|
||||
let patientsCountSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
patientsCountSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
`;
|
||||
} else {
|
||||
patientsCountSql = `SELECT COUNT(DISTINCT "patientId")::int AS cnt FROM "Payment"`;
|
||||
}
|
||||
const patientsCntRows = (await prisma.$queryRawUnsafe(
|
||||
patientsCountSql
|
||||
)) as { cnt: number }[];
|
||||
const totalPatients = patientsCntRows?.[0]?.cnt ?? 0;
|
||||
|
||||
// totalOutstanding: sum of (charges - paid - adjusted) across patients, using payments in range
|
||||
let outstandingSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
} else {
|
||||
outstandingSql = `
|
||||
SELECT COALESCE(SUM(
|
||||
COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)
|
||||
),0)::numeric(14,2) AS outstanding
|
||||
FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
}
|
||||
const outstandingRows = (await prisma.$queryRawUnsafe(
|
||||
outstandingSql
|
||||
)) as { outstanding: string }[];
|
||||
const totalOutstanding = Number(outstandingRows?.[0]?.outstanding ?? 0);
|
||||
|
||||
// totalCollected: sum(totalPaid) in the range
|
||||
let collSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart} AND "createdAt" <= ${toNextStart}`;
|
||||
} else if (hasFrom) {
|
||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" >= ${fromStart}`;
|
||||
} else if (hasTo) {
|
||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment" WHERE "createdAt" <= ${toNextStart}`;
|
||||
} else {
|
||||
collSql = `SELECT COALESCE(SUM("totalPaid"),0)::numeric(14,2) AS collected FROM "Payment"`;
|
||||
}
|
||||
const collRows = (await prisma.$queryRawUnsafe(collSql)) as {
|
||||
collected: string;
|
||||
}[];
|
||||
const totalCollected = Number(collRows?.[0]?.collected ?? 0);
|
||||
|
||||
// NEW: patientsWithBalance: number of patients whose (charges - paid - adjusted) > 0, within the date range
|
||||
let patientsWithBalanceSql = "";
|
||||
if (hasFrom && hasTo) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
|
||||
`;
|
||||
} else if (hasFrom) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" >= ${fromStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
|
||||
`;
|
||||
} else if (hasTo) {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
WHERE pay."createdAt" <= ${toNextStart}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
|
||||
`;
|
||||
} else {
|
||||
patientsWithBalanceSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0
|
||||
`;
|
||||
}
|
||||
const pwbRows = (await prisma.$queryRawUnsafe(
|
||||
patientsWithBalanceSql
|
||||
)) as { cnt: number }[];
|
||||
const patientsWithBalance = pwbRows?.[0]?.cnt ?? 0;
|
||||
|
||||
return {
|
||||
totalPatients,
|
||||
totalOutstanding,
|
||||
totalCollected,
|
||||
patientsWithBalance,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[paymentsReportsStorage.getSummary] error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all patients that currently have an outstanding balance (>0)
|
||||
* Optionally filtered by date range.
|
||||
*/
|
||||
/**
|
||||
* Cursor-based getPatientsWithBalances
|
||||
*/
|
||||
async getPatientsWithBalances(
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
) {
|
||||
try {
|
||||
type RawRow = {
|
||||
patient_id: number;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
total_charges: string;
|
||||
total_paid: string;
|
||||
total_adjusted: string;
|
||||
current_balance: string;
|
||||
last_payment_date: Date | null;
|
||||
last_appointment_date: Date | null;
|
||||
};
|
||||
|
||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||
const cursor = decodeCursor(cursorToken);
|
||||
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
||||
const fromStart = isoStartOfDayLiteral(from); // 'YYYY-MM-DDT00:00:00.000Z'
|
||||
const toNextStart = isoStartOfNextDayLiteral(to); // 'YYYY-MM-DDT00:00:00.000Z' of next day
|
||||
|
||||
// Build payment subquery (aggregated payments by patient, filtered by createdAt if provided)
|
||||
const paymentWhereClause =
|
||||
hasFrom && hasTo
|
||||
? `WHERE pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `WHERE pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `WHERE pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
|
||||
const pmSubquery = `
|
||||
(
|
||||
SELECT
|
||||
pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(12,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(12,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(12,2) AS total_adjusted,
|
||||
MAX(pay."createdAt") AS last_payment_date
|
||||
FROM "Payment" pay
|
||||
${paymentWhereClause}
|
||||
GROUP BY pay."patientId"
|
||||
) pm
|
||||
`;
|
||||
|
||||
// Build keyset predicate if cursor provided.
|
||||
// Ordering used: pm.last_payment_date DESC NULLS LAST, p."createdAt" DESC, p.id DESC
|
||||
// For keyset, we need to fetch rows strictly "less than" the cursor in this ordering.
|
||||
let keysetPredicate = "";
|
||||
if (cursor) {
|
||||
const lp = cursor.lastPaymentDate
|
||||
? `'${cursor.lastPaymentDate}'`
|
||||
: "NULL";
|
||||
const id = Number(cursor.lastPatientId);
|
||||
|
||||
// We handle NULL last_payment_date ordering: since we use "NULLS LAST" in ORDER BY,
|
||||
// rows with last_payment_date = NULL are considered *after* any non-null dates.
|
||||
// To page correctly when cursor's lastPaymentDate is null, we compare accordingly.
|
||||
// This predicate tries to cover both cases.
|
||||
keysetPredicate = `
|
||||
AND (
|
||||
(pm.last_payment_date IS NOT NULL AND ${lp} IS NOT NULL AND (
|
||||
pm.last_payment_date < ${lp}
|
||||
OR (pm.last_payment_date = ${lp} AND p.id < ${id})
|
||||
))
|
||||
OR (pm.last_payment_date IS NULL AND ${lp} IS NOT NULL)
|
||||
OR (pm.last_payment_date IS NULL AND ${lp} IS NULL AND p.id < ${id})
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
p.id AS patient_id,
|
||||
p."firstName" AS first_name,
|
||||
p."lastName" AS last_name,
|
||||
COALESCE(pm.total_charges,0)::numeric(12,2) AS total_charges,
|
||||
COALESCE(pm.total_paid,0)::numeric(12,2) AS total_paid,
|
||||
COALESCE(pm.total_adjusted,0)::numeric(12,2) AS total_adjusted,
|
||||
(COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0))::numeric(12,2) AS current_balance,
|
||||
pm.last_payment_date,
|
||||
apt.last_appointment_date
|
||||
FROM "Patient" p
|
||||
LEFT JOIN ${pmSubquery} ON pm.patient_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date
|
||||
FROM "Appointment"
|
||||
GROUP BY "patientId"
|
||||
) apt ON apt.patient_id = p.id
|
||||
WHERE (COALESCE(pm.total_charges,0) - COALESCE(pm.total_paid,0) - COALESCE(pm.total_adjusted,0)) > 0
|
||||
`;
|
||||
|
||||
const orderBy = `ORDER BY pm.last_payment_date DESC NULLS LAST, p.id DESC`;
|
||||
const limitClause = `LIMIT ${safeLimit}`;
|
||||
|
||||
const query = `
|
||||
${baseSelect}
|
||||
${cursor ? keysetPredicate : ""}
|
||||
${orderBy}
|
||||
${limitClause};
|
||||
`;
|
||||
|
||||
const rows = (await prisma.$queryRawUnsafe(query)) as RawRow[];
|
||||
|
||||
// Build nextCursor from last returned row (if any)
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
// Explicitly handle empty result set
|
||||
if (rows.length === 0) {
|
||||
nextCursor = null;
|
||||
} else {
|
||||
// rows.length > 0 here, but do an explicit last-check to make TS happy
|
||||
const last = rows[rows.length - 1];
|
||||
if (!last) {
|
||||
// defensive — should not happen, but satisfies strict checks
|
||||
nextCursor = null;
|
||||
} else {
|
||||
const lastPaymentDateIso = last.last_payment_date
|
||||
? new Date(last.last_payment_date).toISOString()
|
||||
: null;
|
||||
|
||||
if (rows.length === safeLimit) {
|
||||
nextCursor = encodeCursor({
|
||||
lastPaymentDate: lastPaymentDateIso,
|
||||
lastPatientId: Number(last.patient_id),
|
||||
});
|
||||
} else {
|
||||
nextCursor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine hasMore: if we returned exactly limit, there *may* be more.
|
||||
const hasMore = rows.length === safeLimit;
|
||||
|
||||
// Convert rows to PatientBalanceRow
|
||||
const balances: PatientBalanceRow[] = rows.map((r) => ({
|
||||
patientId: Number(r.patient_id),
|
||||
firstName: r.first_name,
|
||||
lastName: r.last_name,
|
||||
totalCharges: Number(r.total_charges ?? 0),
|
||||
totalPayments: Number(r.total_paid ?? 0),
|
||||
totalAdjusted: Number(r.total_adjusted ?? 0),
|
||||
currentBalance: Number(r.current_balance ?? 0),
|
||||
lastPaymentDate: r.last_payment_date
|
||||
? new Date(r.last_payment_date).toISOString()
|
||||
: null,
|
||||
lastAppointmentDate: r.last_appointment_date
|
||||
? new Date(r.last_appointment_date).toISOString()
|
||||
: null,
|
||||
}));
|
||||
|
||||
// totalCount: count of patients with positive balance within same payment date filter
|
||||
const countSql = `
|
||||
SELECT COUNT(*)::int AS cnt FROM (
|
||||
SELECT pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
${paymentWhereClause}
|
||||
GROUP BY pay."patientId"
|
||||
) t
|
||||
WHERE (COALESCE(t.total_charges,0) - COALESCE(t.total_paid,0) - COALESCE(t.total_adjusted,0)) > 0;
|
||||
`;
|
||||
const cntRows = (await prisma.$queryRawUnsafe(countSql)) as {
|
||||
cnt: number;
|
||||
}[];
|
||||
const totalCount = cntRows?.[0]?.cnt ?? 0;
|
||||
|
||||
return {
|
||||
balances,
|
||||
totalCount,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[paymentsReportsStorage.getPatientBalances] error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return just the paged balances for a doctor (same logic/filters as previous single-query approach)
|
||||
*/
|
||||
async getPatientsBalancesByDoctor(
|
||||
staffId: number,
|
||||
limit = 25,
|
||||
cursorToken?: string | null,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
balances: PatientBalanceRow[];
|
||||
totalCount: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||
throw new Error("Invalid staffId");
|
||||
}
|
||||
|
||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25));
|
||||
const decoded = decodeCursor(cursorToken);
|
||||
|
||||
// Do NOT accept cursors without staffId — they may belong to another listing.
|
||||
const effectiveCursor =
|
||||
decoded &&
|
||||
typeof decoded.staffId === "number" &&
|
||||
decoded.staffId === Number(staffId)
|
||||
? decoded
|
||||
: null;
|
||||
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
// Use inclusive start-of-day for 'from' and exclusive start-of-next-day for 'to'
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
// Filter payments by createdAt (time window) when provided
|
||||
const paymentTimeFilter =
|
||||
hasFrom && hasTo
|
||||
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `AND pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
|
||||
// Keyset predicate — prefer numeric epoch-ms comparison for stability
|
||||
let pageKeysetPredicate = "";
|
||||
if (effectiveCursor) {
|
||||
// Use epoch ms if present in cursor (more precise); otherwise fall back to timestamptz literal.
|
||||
const hasCursorMs =
|
||||
typeof effectiveCursor.lastPaymentMs === "number" &&
|
||||
!Number.isNaN(effectiveCursor.lastPaymentMs);
|
||||
|
||||
const id = Number(effectiveCursor.lastPatientId);
|
||||
|
||||
if (hasCursorMs) {
|
||||
const lpMs = Number(effectiveCursor.lastPaymentMs);
|
||||
// Compare numeric epoch ms; handle NULL last_payment_date rows too.
|
||||
pageKeysetPredicate = `
|
||||
AND (
|
||||
(p.last_payment_ms IS NOT NULL AND ${lpMs} IS NOT NULL AND (
|
||||
p.last_payment_ms < ${lpMs}
|
||||
OR (p.last_payment_ms = ${lpMs} AND p.id < ${id})
|
||||
))
|
||||
OR (p.last_payment_ms IS NULL AND ${lpMs} IS NOT NULL)
|
||||
OR (p.last_payment_ms IS NULL AND ${lpMs} IS NULL AND p.id < ${id})
|
||||
)
|
||||
`;
|
||||
} else {
|
||||
// fall back to timestamptz string literal (older cursor)
|
||||
const lpLiteral = effectiveCursor.lastPaymentDate
|
||||
? `('${effectiveCursor.lastPaymentDate}'::timestamptz)`
|
||||
: "NULL";
|
||||
pageKeysetPredicate = `
|
||||
AND (
|
||||
(p.last_payment_date IS NOT NULL AND ${lpLiteral} IS NOT NULL AND (
|
||||
p.last_payment_date < ${lpLiteral}
|
||||
OR (p.last_payment_date = ${lpLiteral} AND p.id < ${id})
|
||||
))
|
||||
OR (p.last_payment_date IS NULL AND ${lpLiteral} IS NOT NULL)
|
||||
OR (p.last_payment_date IS NULL AND ${lpLiteral} IS NULL AND p.id < ${id})
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const paymentsJoinForPatients =
|
||||
hasFrom || hasTo
|
||||
? "INNER JOIN payments_agg pa ON pa.patient_id = p.id"
|
||||
: "LEFT JOIN payments_agg pa ON pa.patient_id = p.id";
|
||||
|
||||
// Common CTEs (identical to previous single-query approach)
|
||||
const commonCtes = `
|
||||
WITH
|
||||
staff_patients AS (
|
||||
SELECT DISTINCT "patientId" AS patient_id
|
||||
FROM "Appointment"
|
||||
WHERE "staffId" = ${Number(staffId)}
|
||||
),
|
||||
|
||||
payments_agg AS (
|
||||
SELECT
|
||||
pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted,
|
||||
MAX(pay."createdAt") AS last_payment_date
|
||||
FROM "Payment" pay
|
||||
JOIN "Claim" c ON pay."claimId" = c.id
|
||||
WHERE c."staffId" = ${Number(staffId)}
|
||||
${paymentTimeFilter}
|
||||
GROUP BY pay."patientId"
|
||||
),
|
||||
|
||||
last_appointments AS (
|
||||
SELECT "patientId" AS patient_id, MAX("date") AS last_appointment_date
|
||||
FROM "Appointment"
|
||||
GROUP BY "patientId"
|
||||
),
|
||||
|
||||
patients AS (
|
||||
SELECT
|
||||
p.id,
|
||||
p."firstName" AS first_name,
|
||||
p."lastName" AS last_name,
|
||||
COALESCE(pa.total_charges, 0)::numeric(14,2) AS total_charges,
|
||||
COALESCE(pa.total_paid, 0)::numeric(14,2) AS total_paid,
|
||||
COALESCE(pa.total_adjusted, 0)::numeric(14,2) AS total_adjusted,
|
||||
(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0))::numeric(14,2) AS current_balance,
|
||||
pa.last_payment_date,
|
||||
-- epoch milliseconds for last payment date (NULL when last_payment_date is NULL)
|
||||
(CASE WHEN pa.last_payment_date IS NULL THEN NULL
|
||||
ELSE (EXTRACT(EPOCH FROM (pa.last_payment_date AT TIME ZONE 'UTC')) * 1000)::bigint
|
||||
END) AS last_payment_ms,
|
||||
la.last_appointment_date
|
||||
FROM "Patient" p
|
||||
INNER JOIN staff_patients sp ON sp.patient_id = p.id
|
||||
${paymentsJoinForPatients}
|
||||
LEFT JOIN last_appointments la ON la.patient_id = p.id
|
||||
)
|
||||
`;
|
||||
|
||||
// Fetch one extra row to detect whether there's a following page.
|
||||
const fetchLimit = safeLimit + 1;
|
||||
|
||||
const balancesQuery = `
|
||||
${commonCtes}
|
||||
|
||||
SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) AS balances_json FROM (
|
||||
SELECT
|
||||
p.id AS "patientId",
|
||||
p.first_name AS "firstName",
|
||||
p.last_name AS "lastName",
|
||||
p.total_charges::text AS "totalCharges",
|
||||
p.total_paid::text AS "totalPaid",
|
||||
p.total_adjusted::text AS "totalAdjusted",
|
||||
p.current_balance::text AS "currentBalance",
|
||||
-- ISO text for UI (optional)
|
||||
to_char(p.last_payment_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastPaymentDate",
|
||||
-- epoch ms (number) used for precise keyset comparisons
|
||||
p.last_payment_ms::bigint AS "lastPaymentMs",
|
||||
to_char(p.last_appointment_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastAppointmentDate"
|
||||
FROM patients p
|
||||
WHERE 1=1
|
||||
${pageKeysetPredicate}
|
||||
ORDER BY p.last_payment_date DESC NULLS LAST, p.id DESC
|
||||
LIMIT ${fetchLimit}
|
||||
) t;
|
||||
`;
|
||||
|
||||
const balancesRawRows = (await prisma.$queryRawUnsafe(
|
||||
balancesQuery
|
||||
)) as Array<{ balances_json?: any }>;
|
||||
const balancesJson = (balancesRawRows?.[0]?.balances_json as any) ?? [];
|
||||
const fetchedArr = Array.isArray(balancesJson) ? balancesJson : [];
|
||||
|
||||
// If we fetched > safeLimit, there is another page.
|
||||
let hasMore = false;
|
||||
let pageRows = fetchedArr;
|
||||
if (fetchedArr.length > safeLimit) {
|
||||
hasMore = true;
|
||||
pageRows = fetchedArr.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
const balances: PatientBalanceRow[] = (pageRows || []).map((r: any) => ({
|
||||
patientId: Number(r.patientId),
|
||||
firstName: r.firstName ?? null,
|
||||
lastName: r.lastName ?? null,
|
||||
totalCharges: Number(r.totalCharges ?? 0),
|
||||
totalPayments: Number(r.totalPaid ?? 0),
|
||||
totalAdjusted: Number(r.totalAdjusted ?? 0),
|
||||
currentBalance: Number(r.currentBalance ?? 0),
|
||||
lastPaymentDate: r.lastPaymentDate
|
||||
? new Date(r.lastPaymentDate).toISOString()
|
||||
: null,
|
||||
lastAppointmentDate: r.lastAppointmentDate
|
||||
? new Date(r.lastAppointmentDate).toISOString()
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Build nextCursor only when we actually have more rows.
|
||||
let nextCursor: string | null = null;
|
||||
if (hasMore) {
|
||||
// If we somehow have no balances for this page (defensive), don't build a cursor.
|
||||
if (!Array.isArray(balances) || balances.length === 0) {
|
||||
nextCursor = null;
|
||||
} else {
|
||||
// Now balances.length > 0, so last is definitely present.
|
||||
const lastIndex = balances.length - 1;
|
||||
const last = balances[lastIndex];
|
||||
if (!last) {
|
||||
// defensive fallback (shouldn't happen because of length check)
|
||||
nextCursor = null;
|
||||
} else {
|
||||
// get the raw JSON row corresponding to the last returned page row so we can read the numeric ms
|
||||
// `pageRows` is the array of raw JSON objects fetched from the DB (slice(0, safeLimit) applied above).
|
||||
const corresponding = (pageRows as any[])[pageRows.length - 1];
|
||||
const lastPaymentMs =
|
||||
typeof corresponding?.lastPaymentMs === "number"
|
||||
? Number(corresponding.lastPaymentMs)
|
||||
: corresponding?.lastPaymentMs === null
|
||||
? null
|
||||
: undefined;
|
||||
|
||||
nextCursor = encodeCursor({
|
||||
staffId: Number(staffId),
|
||||
lastPaymentDate: last.lastPaymentDate ?? null,
|
||||
lastPatientId: Number(last.patientId),
|
||||
lastPaymentMs: lastPaymentMs ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count query (same logic as before)
|
||||
const countQuery = `
|
||||
${commonCtes}
|
||||
|
||||
SELECT
|
||||
(CASE WHEN ${hasFrom || hasTo ? "true" : "false"} THEN
|
||||
(SELECT COUNT(DISTINCT pa.patient_id) FROM payments_agg pa)
|
||||
ELSE
|
||||
(SELECT COUNT(*)::int FROM staff_patients)
|
||||
END) AS total_count;
|
||||
`;
|
||||
|
||||
const countRows = (await prisma.$queryRawUnsafe(countQuery)) as Array<{
|
||||
total_count?: number;
|
||||
}>;
|
||||
const totalCount = Number(countRows?.[0]?.total_count ?? 0);
|
||||
|
||||
return {
|
||||
balances,
|
||||
totalCount,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Return only the summary data for a doctor (same logic/filters as previous single-query approach)
|
||||
*/
|
||||
async getSummaryByDoctor(
|
||||
staffId: number,
|
||||
from?: Date | null,
|
||||
to?: Date | null
|
||||
): Promise<{
|
||||
totalPatients: number;
|
||||
totalOutstanding: number;
|
||||
totalCollected: number;
|
||||
patientsWithBalance: number;
|
||||
}> {
|
||||
if (!Number.isFinite(Number(staffId)) || Number(staffId) <= 0) {
|
||||
throw new Error("Invalid staffId");
|
||||
}
|
||||
|
||||
const hasFrom = from !== undefined && from !== null;
|
||||
const hasTo = to !== undefined && to !== null;
|
||||
|
||||
const fromStart = isoStartOfDayLiteral(from);
|
||||
const toNextStart = isoStartOfNextDayLiteral(to);
|
||||
|
||||
const paymentTimeFilter =
|
||||
hasFrom && hasTo
|
||||
? `AND pay."createdAt" >= ${fromStart} AND pay."createdAt" <= ${toNextStart}`
|
||||
: hasFrom
|
||||
? `AND pay."createdAt" >= ${fromStart}`
|
||||
: hasTo
|
||||
? `AND pay."createdAt" <= ${toNextStart}`
|
||||
: "";
|
||||
|
||||
const summaryQuery = `
|
||||
WITH
|
||||
payments_agg AS (
|
||||
SELECT
|
||||
pay."patientId" AS patient_id,
|
||||
SUM(pay."totalBilled")::numeric(14,2) AS total_charges,
|
||||
SUM(pay."totalPaid")::numeric(14,2) AS total_paid,
|
||||
SUM(pay."totalAdjusted")::numeric(14,2) AS total_adjusted
|
||||
FROM "Payment" pay
|
||||
JOIN "Claim" c ON pay."claimId" = c.id
|
||||
WHERE c."staffId" = ${Number(staffId)}
|
||||
${paymentTimeFilter}
|
||||
GROUP BY pay."patientId"
|
||||
)
|
||||
SELECT json_build_object(
|
||||
'totalPatients', COALESCE(COUNT(DISTINCT pa.patient_id),0),
|
||||
'totalOutstanding', COALESCE(SUM(COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)),0)::text,
|
||||
'totalCollected', COALESCE(SUM(COALESCE(pa.total_paid,0)),0)::text,
|
||||
'patientsWithBalance', COALESCE(SUM(CASE WHEN (COALESCE(pa.total_charges,0) - COALESCE(pa.total_paid,0) - COALESCE(pa.total_adjusted,0)) > 0 THEN 1 ELSE 0 END),0)
|
||||
) AS summary_json
|
||||
FROM payments_agg pa;
|
||||
`;
|
||||
|
||||
const rows = (await prisma.$queryRawUnsafe(summaryQuery)) as Array<{
|
||||
summary_json?: any;
|
||||
}>;
|
||||
|
||||
const summaryRaw = (rows?.[0]?.summary_json as any) ?? {};
|
||||
|
||||
return {
|
||||
totalPatients: Number(summaryRaw.totalPatients ?? 0),
|
||||
totalOutstanding: Number(summaryRaw.totalOutstanding ?? 0),
|
||||
totalCollected: Number(summaryRaw.totalCollected ?? 0),
|
||||
patientsWithBalance: Number(summaryRaw.patientsWithBalance ?? 0),
|
||||
};
|
||||
},
|
||||
};
|
||||
263
apps/Backend/src/storage/payments-storage.ts
Executable file
263
apps/Backend/src/storage/payments-storage.ts
Executable file
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
InsertPayment,
|
||||
Payment,
|
||||
PaymentWithExtras,
|
||||
UpdatePayment,
|
||||
} from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
// Payment methods:
|
||||
getPayment(id: number): Promise<Payment | undefined>;
|
||||
createPayment(data: InsertPayment): Promise<Payment>;
|
||||
updatePayment(id: number, updates: UpdatePayment): Promise<Payment>;
|
||||
updatePaymentStatus(
|
||||
id: number,
|
||||
updates: UpdatePayment,
|
||||
updatedById?: number
|
||||
): Promise<Payment>;
|
||||
deletePayment(id: number, userId: number): Promise<void>;
|
||||
getPaymentById(id: number): Promise<PaymentWithExtras | null>;
|
||||
getRecentPaymentsByPatientId(
|
||||
patientId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<PaymentWithExtras[] | null>;
|
||||
getTotalPaymentCountByPatient(patientId: number): Promise<number>;
|
||||
getPaymentsByClaimId(claimId: number): Promise<PaymentWithExtras | null>;
|
||||
getRecentPayments(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<PaymentWithExtras[]>;
|
||||
getPaymentsByDateRange(from: Date, to: Date): Promise<PaymentWithExtras[]>;
|
||||
getTotalPaymentCount(): Promise<number>;
|
||||
}
|
||||
|
||||
export const paymentsStorage: IStorage = {
|
||||
// Payment Methods
|
||||
async getPayment(id: number): Promise<Payment | undefined> {
|
||||
const payment = await db.payment.findUnique({ where: { id } });
|
||||
return payment ?? undefined;
|
||||
},
|
||||
|
||||
async createPayment(payment: InsertPayment): Promise<Payment> {
|
||||
return db.payment.create({ data: payment as Payment });
|
||||
},
|
||||
|
||||
async updatePayment(id: number, updates: UpdatePayment): Promise<Payment> {
|
||||
const existing = await db.payment.findFirst({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
return db.payment.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
async updatePaymentStatus(
|
||||
id: number,
|
||||
updates: UpdatePayment,
|
||||
updatedById?: number
|
||||
): Promise<Payment> {
|
||||
const existing = await db.payment.findFirst({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error("Payment not found");
|
||||
}
|
||||
|
||||
const data: any = { ...updates };
|
||||
if (typeof updatedById === "number") data.updatedById = updatedById;
|
||||
|
||||
return db.payment.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
async deletePayment(id: number, userId: number): Promise<void> {
|
||||
const existing = await db.payment.findFirst({ where: { id, userId } });
|
||||
if (!existing) {
|
||||
throw new Error("Not authorized or payment not found");
|
||||
}
|
||||
|
||||
await db.payment.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async getRecentPaymentsByPatientId(
|
||||
patientId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<PaymentWithExtras[]> {
|
||||
const payments = await db.payment.findMany({
|
||||
where: { patientId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
claim: {
|
||||
include: {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
return payments.map((payment) => ({
|
||||
...payment,
|
||||
patientName: payment.claim?.patientName ?? "",
|
||||
paymentDate: payment.createdAt,
|
||||
paymentMethod: payment.serviceLineTransactions[0]?.method ?? "OTHER",
|
||||
}));
|
||||
},
|
||||
|
||||
async getTotalPaymentCountByPatient(patientId: number): Promise<number> {
|
||||
return db.payment.count({
|
||||
where: { patientId },
|
||||
});
|
||||
},
|
||||
|
||||
async getPaymentById(id: number): Promise<PaymentWithExtras | null> {
|
||||
const payment = await db.payment.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
claim: {
|
||||
include: {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) return null;
|
||||
|
||||
return {
|
||||
...payment,
|
||||
patientName: payment.claim?.patientName ?? "",
|
||||
paymentDate: payment.createdAt,
|
||||
paymentMethod: payment.serviceLineTransactions[0]?.method ?? "OTHER",
|
||||
};
|
||||
},
|
||||
|
||||
async getPaymentsByClaimId(
|
||||
claimId: number
|
||||
): Promise<PaymentWithExtras | null> {
|
||||
const payment = await db.payment.findFirst({
|
||||
where: { claimId },
|
||||
include: {
|
||||
claim: {
|
||||
include: {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) return null;
|
||||
|
||||
return {
|
||||
...payment,
|
||||
patientName: payment.claim?.patientName ?? "",
|
||||
paymentDate: payment.createdAt,
|
||||
paymentMethod: payment.serviceLineTransactions[0]?.method ?? "OTHER",
|
||||
};
|
||||
},
|
||||
|
||||
async getRecentPayments(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<PaymentWithExtras[]> {
|
||||
const payments = await db.payment.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
include: {
|
||||
claim: {
|
||||
include: {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
return payments.map((payment) => ({
|
||||
...payment,
|
||||
patientName: payment.claim?.patientName ?? "",
|
||||
paymentDate: payment.createdAt,
|
||||
paymentMethod: payment.serviceLineTransactions[0]?.method ?? "OTHER",
|
||||
}));
|
||||
},
|
||||
|
||||
async getPaymentsByDateRange(
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<PaymentWithExtras[]> {
|
||||
const payments = await db.payment.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
claim: {
|
||||
include: {
|
||||
serviceLines: true,
|
||||
},
|
||||
},
|
||||
serviceLines: true,
|
||||
serviceLineTransactions: {
|
||||
include: {
|
||||
serviceLine: true,
|
||||
},
|
||||
},
|
||||
updatedBy: true,
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
|
||||
return payments.map((payment) => ({
|
||||
...payment,
|
||||
patientName: payment.claim?.patientName ?? "",
|
||||
paymentDate: payment.createdAt,
|
||||
paymentMethod: payment.serviceLineTransactions[0]?.method ?? "OTHER",
|
||||
}));
|
||||
},
|
||||
|
||||
async getTotalPaymentCount(): Promise<number> {
|
||||
return db.payment.count();
|
||||
},
|
||||
};
|
||||
61
apps/Backend/src/storage/staff-storage.ts
Executable file
61
apps/Backend/src/storage/staff-storage.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
import { Staff } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IStorage {
|
||||
getStaff(id: number): Promise<Staff | undefined>;
|
||||
getAllStaff(): Promise<Staff[]>;
|
||||
createStaff(staff: Staff): Promise<Staff>;
|
||||
updateStaff(id: number, updates: Partial<Staff>): Promise<Staff | undefined>;
|
||||
deleteStaff(id: number): Promise<boolean>;
|
||||
countAppointmentsByStaffId(staffId: number): Promise<number>;
|
||||
countClaimsByStaffId(staffId: number): Promise<number>;
|
||||
}
|
||||
|
||||
export const staffStorage: IStorage = {
|
||||
// Staff methods
|
||||
async getStaff(id: number): Promise<Staff | undefined> {
|
||||
const staff = await db.staff.findUnique({ where: { id } });
|
||||
return staff ?? undefined;
|
||||
},
|
||||
|
||||
async getAllStaff(): Promise<Staff[]> {
|
||||
const staff = await db.staff.findMany();
|
||||
return staff;
|
||||
},
|
||||
|
||||
async createStaff(staff: Staff): Promise<Staff> {
|
||||
const createdStaff = await db.staff.create({
|
||||
data: staff,
|
||||
});
|
||||
return createdStaff;
|
||||
},
|
||||
|
||||
async updateStaff(
|
||||
id: number,
|
||||
updates: Partial<Staff>
|
||||
): Promise<Staff | undefined> {
|
||||
const updatedStaff = await db.staff.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
return updatedStaff ?? undefined;
|
||||
},
|
||||
|
||||
async deleteStaff(id: number): Promise<boolean> {
|
||||
try {
|
||||
await db.staff.delete({ where: { id } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting staff:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async countAppointmentsByStaffId(staffId: number): Promise<number> {
|
||||
return await db.appointment.count({ where: { staffId } });
|
||||
},
|
||||
|
||||
async countClaimsByStaffId(staffId: number): Promise<number> {
|
||||
return await db.claim.count({ where: { staffId } });
|
||||
},
|
||||
};
|
||||
53
apps/Backend/src/storage/users-storage.ts
Executable file
53
apps/Backend/src/storage/users-storage.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import { InsertUser, User } from "@repo/db/types";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export interface IUsersStorage {
|
||||
// User methods
|
||||
getUser(id: number): Promise<User | undefined>;
|
||||
getUsers(limit: number, offset: number): Promise<User[]>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
createUser(user: InsertUser): Promise<User>;
|
||||
updateUser(id: number, updates: Partial<User>): Promise<User | undefined>;
|
||||
deleteUser(id: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const usersStorage: IUsersStorage = {
|
||||
// User methods
|
||||
async getUser(id: number): Promise<User | undefined> {
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
return user ?? undefined;
|
||||
},
|
||||
|
||||
async getUsers(limit: number, offset: number): Promise<User[]> {
|
||||
return await db.user.findMany({ skip: offset, take: limit });
|
||||
},
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
const user = await db.user.findUnique({ where: { username } });
|
||||
return user ?? undefined;
|
||||
},
|
||||
|
||||
async createUser(user: InsertUser): Promise<User> {
|
||||
return await db.user.create({ data: user as User });
|
||||
},
|
||||
|
||||
async updateUser(
|
||||
id: number,
|
||||
updates: Partial<User>
|
||||
): Promise<User | undefined> {
|
||||
try {
|
||||
return await db.user.update({ where: { id }, data: updates });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteUser(id: number): Promise<boolean> {
|
||||
try {
|
||||
await db.user.delete({ where: { id } });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
10
apps/Backend/src/types/express.types.d.ts
vendored
Executable file
10
apps/Backend/src/types/express.types.d.ts
vendored
Executable file
@@ -0,0 +1,10 @@
|
||||
import { User } from "@repo/db/client";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface User {
|
||||
id: number;
|
||||
// include any other properties
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/Backend/src/utils/dateUtils.ts
Executable file
61
apps/Backend/src/utils/dateUtils.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
|
||||
/**
|
||||
* Convert any OCR string-like value into a safe string.
|
||||
*/
|
||||
export function toStr(val: string | number | null | undefined): string {
|
||||
if (val == null) return "";
|
||||
return String(val).trim();
|
||||
}
|
||||
/**
|
||||
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
|
||||
* Example: "070825" → 2025-08-07.
|
||||
*/
|
||||
export function convertOCRDate(input: string | number | null | undefined): Date {
|
||||
const raw = toStr(input);
|
||||
|
||||
if (!/^\d{6}$/.test(raw)) {
|
||||
throw new Error(`Invalid OCR date format: ${raw}`);
|
||||
}
|
||||
|
||||
const month = parseInt(raw.slice(0, 2), 10) - 1;
|
||||
const day = parseInt(raw.slice(2, 4), 10);
|
||||
const year2 = parseInt(raw.slice(4, 6), 10);
|
||||
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a DOB value to "YYYY-MM-DD" string expected by the Python agent.
|
||||
* - If dob is already "YYYY-MM-DD" string, returns it.
|
||||
* - If dob is an ISO datetime string or Date, returns YYYY-MM-DD derived from UTC parts (no timezone shifts).
|
||||
* - Returns null for invalid values.
|
||||
*/
|
||||
export function formatDobForAgent(dob: Date | string | null | undefined): string | null {
|
||||
if (!dob) return null;
|
||||
|
||||
// If it's a string in exact YYYY-MM-DD format, return as-is (most ideal).
|
||||
if (typeof dob === "string") {
|
||||
const simpleDateMatch = /^\d{4}-\d{2}-\d{2}$/.test(dob);
|
||||
if (simpleDateMatch) return dob;
|
||||
|
||||
// Otherwise try parsing as a Date/ISO string and use UTC parts
|
||||
const parsed = new Date(dob);
|
||||
if (isNaN(parsed.getTime())) return null;
|
||||
const y = parsed.getUTCFullYear();
|
||||
const m = String(parsed.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(parsed.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// If it's a Date object, use UTC getters to avoid timezone shifts
|
||||
if (dob instanceof Date) {
|
||||
if (isNaN(dob.getTime())) return null;
|
||||
const y = dob.getUTCFullYear();
|
||||
const m = String(dob.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(dob.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
146
apps/Backend/src/utils/emptyTempFolder.ts
Executable file
146
apps/Backend/src/utils/emptyTempFolder.ts
Executable file
@@ -0,0 +1,146 @@
|
||||
// ../utils/emptyTempFolder.ts
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
/**
|
||||
* Remove EVERYTHING under the parent folder that contains filePath.
|
||||
* - Does NOT remove the parent folder itself (only its children).
|
||||
* - Uses fs.rm with recursive+force if available.
|
||||
* - Falls back to reliable manual recursion otherwise.
|
||||
* - Logs folder contents before and after.
|
||||
*
|
||||
* Throws on critical safety checks.
|
||||
*/
|
||||
export async function emptyFolderContainingFile(filePath?: string | null): Promise<void> {
|
||||
if (!filePath) return;
|
||||
|
||||
const absFile = path.resolve(String(filePath));
|
||||
const folder = path.dirname(absFile);
|
||||
|
||||
// Safety checks
|
||||
if (!folder) {
|
||||
throw new Error(`Refusing to clean: resolved folder empty for filePath=${filePath}`);
|
||||
}
|
||||
const parsed = path.parse(folder);
|
||||
if (folder === parsed.root) {
|
||||
throw new Error(`Refusing to clean root folder: ${folder}`);
|
||||
}
|
||||
const home = os.homedir();
|
||||
if (home && path.resolve(home) === path.resolve(folder)) {
|
||||
throw new Error(`Refusing to clean user's home directory: ${folder}`);
|
||||
}
|
||||
const base = path.basename(folder);
|
||||
if (!base || base.length < 2) {
|
||||
throw new Error(`Refusing to clean suspicious folder: ${folder}`);
|
||||
}
|
||||
|
||||
console.log(`[cleanup] emptyFolderContainingFile called for filePath=${filePath}`);
|
||||
console.log(`[cleanup] target folder=${folder}`);
|
||||
|
||||
// Read and log contents before
|
||||
let before: string[] = [];
|
||||
try {
|
||||
before = await fs.readdir(folder);
|
||||
console.log(`[cleanup] before (${before.length}):`, before);
|
||||
} catch (err) {
|
||||
console.error(`[cleanup] failed to read folder ${folder} before removal:`, err);
|
||||
// If we can't read, bail out (safety)
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Helper fallback: recursive remove
|
||||
async function recursiveRemove(p: string): Promise<void> {
|
||||
try {
|
||||
const st = await fs.lstat(p);
|
||||
if (st.isDirectory()) {
|
||||
const children = await fs.readdir(p);
|
||||
for (const c of children) {
|
||||
await recursiveRemove(path.join(p, c));
|
||||
}
|
||||
// remove directory after children removed
|
||||
try {
|
||||
await fs.rmdir(p);
|
||||
} catch (err) {
|
||||
// log and continue
|
||||
console.error(`[cleanup] rmdir failed for ${p}:`, err);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.unlink(p);
|
||||
} catch (err: any) {
|
||||
// On EPERM try chmod and retry once
|
||||
if (err.code === "EPERM" || err.code === "EACCES") {
|
||||
try {
|
||||
fsSync.chmodSync(p, 0o666);
|
||||
await fs.unlink(p);
|
||||
} catch (retryErr) {
|
||||
console.error(`[cleanup] unlink after chmod failed for ${p}:`, retryErr);
|
||||
throw retryErr;
|
||||
}
|
||||
} else if (err.code === "ENOENT") {
|
||||
// already gone — ignore
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") return; // already gone
|
||||
// rethrow to allow caller to log
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove everything under folder (each top-level entry)
|
||||
for (const name of before) {
|
||||
const full = path.join(folder, name);
|
||||
try {
|
||||
if (typeof (fs as any).rm === "function") {
|
||||
// Node >= 14.14/16+: use fs.rm with recursive & force
|
||||
await (fs as any).rm(full, { recursive: true, force: true });
|
||||
console.log(`[cleanup] removed (fs.rm): ${full}`);
|
||||
} else {
|
||||
// fallback
|
||||
await recursiveRemove(full);
|
||||
console.log(`[cleanup] removed (recursive): ${full}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[cleanup] failed to remove ${full}:`, err);
|
||||
// Try chmod and retry once for stubborn files
|
||||
try {
|
||||
if (fsSync.existsSync(full)) {
|
||||
console.log(`[cleanup] attempting chmod+retry for ${full}`);
|
||||
try {
|
||||
fsSync.chmodSync(full, 0o666);
|
||||
if (typeof (fs as any).rm === "function") {
|
||||
await (fs as any).rm(full, { recursive: true, force: true });
|
||||
} else {
|
||||
await recursiveRemove(full);
|
||||
}
|
||||
console.log(`[cleanup] removed after chmod: ${full}`);
|
||||
continue;
|
||||
} catch (retryErr) {
|
||||
console.error(`[cleanup] retry after chmod failed for ${full}:`, retryErr);
|
||||
}
|
||||
} else {
|
||||
console.log(`[cleanup] ${full} disappeared before retry`);
|
||||
}
|
||||
} catch (permErr) {
|
||||
console.error(`[cleanup] chmod/retry error for ${full}:`, permErr);
|
||||
}
|
||||
// continue to next entry even if this failed
|
||||
}
|
||||
}
|
||||
|
||||
// Read and log contents after
|
||||
try {
|
||||
const after = await fs.readdir(folder);
|
||||
console.log(`[cleanup] after (${after.length}):`, after);
|
||||
} catch (err) {
|
||||
console.error(`[cleanup] failed to read folder ${folder} after removal:`, err);
|
||||
}
|
||||
|
||||
console.log(`[cleanup] finished cleaning folder: ${folder}`);
|
||||
}
|
||||
27
apps/Backend/src/utils/helpers.ts
Executable file
27
apps/Backend/src/utils/helpers.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
export function normalizeInsuranceId(raw: unknown): string | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
|
||||
// Accept numbers too (e.g. 12345), but prefer strings
|
||||
let s: string;
|
||||
if (typeof raw === "number") {
|
||||
s = String(raw);
|
||||
} else if (typeof raw === "string") {
|
||||
s = raw;
|
||||
} else {
|
||||
// Not acceptable type
|
||||
throw new Error("Insurance ID must be a numeric string.");
|
||||
}
|
||||
|
||||
// Remove all whitespace
|
||||
const cleaned = s.replace(/\s+/g, "");
|
||||
|
||||
// If empty after cleaning, treat as undefined
|
||||
if (cleaned === "") return undefined;
|
||||
|
||||
// Only digits allowed (since you said it's numeric)
|
||||
if (!/^\d+$/.test(cleaned)) {
|
||||
throw new Error("Insurance ID must contain only digits.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
12
apps/Backend/src/utils/prismaFileUtils.ts
Executable file
12
apps/Backend/src/utils/prismaFileUtils.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Helper: convert Prisma CloudFile result to JSON-friendly object.
|
||||
*/
|
||||
export function serializeFile(f: any) {
|
||||
if (!f) return null;
|
||||
return {
|
||||
...f,
|
||||
fileSize: typeof f.fileSize === "bigint" ? f.fileSize.toString() : f.fileSize,
|
||||
createdAt: f.createdAt?.toISOString?.(),
|
||||
updatedAt: f.updatedAt?.toISOString?.(),
|
||||
};
|
||||
}
|
||||
12
apps/Backend/tsconfig.json
Executable file
12
apps/Backend/tsconfig.json
Executable file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": true
|
||||
},
|
||||
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
5
apps/Frontend/.env
Executable file
5
apps/Frontend/.env
Executable file
@@ -0,0 +1,5 @@
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
VITE_API_BASE_URL_BACKEND=
|
||||
# VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
5
apps/Frontend/.env.example
Executable file
5
apps/Frontend/.env.example
Executable file
@@ -0,0 +1,5 @@
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
VITE_API_BASE_URL_BACKEND=http://192.168.1.37:5000
|
||||
# VITE_API_BASE_URL_BACKEND=http://localhost:5000
|
||||
24
apps/Frontend/.gitignore
vendored
Executable file
24
apps/Frontend/.gitignore
vendored
Executable file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
54
apps/Frontend/README.md
Executable file
54
apps/Frontend/README.md
Executable file
@@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
apps/Frontend/eslint.config.js
Executable file
28
apps/Frontend/eslint.config.js
Executable file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
apps/Frontend/index.html
Executable file
13
apps/Frontend/index.html
Executable file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Dental Office Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
105
apps/Frontend/package.json
Executable file
105
apps/Frontend/package.json
Executable file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-context-menu": "^2.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-menubar": "^1.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-toast": "^1.2.7",
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@react-pdf-viewer/default-layout": "^3.12.0",
|
||||
"@replit/vite-plugin-shadcn-theme-json": "^0.0.4",
|
||||
"@repo/db": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"postcss": "^8.4.47",
|
||||
"react": "^19.1.0",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"use-debounce": "^10.0.4",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.7.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/tailwindcss": "^3.0.11",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
6
apps/Frontend/postcss.config.js
Executable file
6
apps/Frontend/postcss.config.js
Executable file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
apps/Frontend/public/favicon.svg
Executable file
0
apps/Frontend/public/favicon.svg
Executable file
60
apps/Frontend/src/App.jsx
Executable file
60
apps/Frontend/src/App.jsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import { Switch, Route, Redirect } from "wouter";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ProtectedRoute } from "./lib/protected-route";
|
||||
import { AuthProvider } from "./hooks/use-auth";
|
||||
import Dashboard from "./pages/dashboard";
|
||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||
const ClaimsPage = lazy(() => import("./pages/claims-page"));
|
||||
const PaymentsPage = lazy(() => import("./pages/payments-page"));
|
||||
const InsuranceStatusPage = lazy(() => import("./pages/insurance-status-page"));
|
||||
const DocumentPage = lazy(() => import("./pages/documents-page"));
|
||||
const DatabaseManagementPage = lazy(() => import("./pages/database-management-page"));
|
||||
const ReportsPage = lazy(() => import("./pages/reports-page"));
|
||||
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
|
||||
const NotFound = lazy(() => import("./pages/not-found"));
|
||||
function Router() {
|
||||
return (<Switch>
|
||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status"/>}/>
|
||||
|
||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />}/>
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />}/>
|
||||
<ProtectedRoute path="/appointments" component={() => <AppointmentsPage />}/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />}/>
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />}/>
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />}/>
|
||||
<ProtectedRoute path="/insurance-status" component={() => <InsuranceStatusPage />}/>
|
||||
<ProtectedRoute path="/payments" component={() => <PaymentsPage />}/>
|
||||
<ProtectedRoute path="/documents" component={() => <DocumentPage />}/>
|
||||
<ProtectedRoute path="/database-management" component={() => <DatabaseManagementPage />}/>
|
||||
<ProtectedRoute path="/reports" component={() => <ReportsPage />}/>
|
||||
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />}/>
|
||||
<Route path="/auth" component={() => <AuthPage />}/>
|
||||
<Route component={() => <NotFound />}/>
|
||||
</Switch>);
|
||||
}
|
||||
function App() {
|
||||
return (<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Router />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>);
|
||||
}
|
||||
export default App;
|
||||
81
apps/Frontend/src/App.tsx
Executable file
81
apps/Frontend/src/App.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import { Switch, Route, Redirect } from "wouter";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ProtectedRoute } from "./lib/protected-route";
|
||||
import { AuthProvider } from "./hooks/use-auth";
|
||||
import Dashboard from "./pages/dashboard";
|
||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||
|
||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||
const ClaimsPage = lazy(() => import("./pages/claims-page"));
|
||||
const PaymentsPage = lazy(() => import("./pages/payments-page"));
|
||||
const InsuranceStatusPage = lazy(
|
||||
() => import("./pages/insurance-status-page")
|
||||
);
|
||||
const DocumentPage = lazy(() => import("./pages/documents-page"));
|
||||
const DatabaseManagementPage = lazy(
|
||||
() => import("./pages/database-management-page")
|
||||
);
|
||||
const ReportsPage = lazy(() => import("./pages/reports-page"));
|
||||
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
|
||||
const NotFound = lazy(() => import("./pages/not-found"));
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
||||
|
||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />} />
|
||||
<ProtectedRoute
|
||||
path="/appointments"
|
||||
component={() => <AppointmentsPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
|
||||
<ProtectedRoute path="/settings" component={() => <SettingsPage />} />
|
||||
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
|
||||
<ProtectedRoute
|
||||
path="/insurance-status"
|
||||
component={() => <InsuranceStatusPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/payments" component={() => <PaymentsPage />} />
|
||||
<ProtectedRoute path="/documents" component={() => <DocumentPage />} />
|
||||
<ProtectedRoute
|
||||
path="/database-management"
|
||||
component={() => <DatabaseManagementPage />}
|
||||
/>
|
||||
<ProtectedRoute path="/reports" component={() => <ReportsPage />} />
|
||||
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
|
||||
<Route path="/auth" component={() => <AuthPage />} />
|
||||
<Route component={() => <NotFound />} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Router />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1191
apps/Frontend/src/assets/data/procedureCodes.json
Executable file
1191
apps/Frontend/src/assets/data/procedureCodes.json
Executable file
File diff suppressed because it is too large
Load Diff
92
apps/Frontend/src/components/analytics/appointments-by-day.tsx
Executable file
92
apps/Frontend/src/components/analytics/appointments-by-day.tsx
Executable file
@@ -0,0 +1,92 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
interface AppointmentsByDayProps {
|
||||
appointments: any[];
|
||||
}
|
||||
|
||||
export function AppointmentsByDay({ appointments }: AppointmentsByDayProps) {
|
||||
const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const countsByDay = daysOfWeek.map((day) => ({ day, count: 0 }));
|
||||
|
||||
// Get current date and set time to start of day (midnight)
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
// Calculate Monday of the current week
|
||||
const day = now.getDay(); // 0 = Sunday, 1 = Monday, ...
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day; // adjust if Sunday
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + diffToMonday);
|
||||
|
||||
// Sunday of the current week
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
|
||||
// Filter appointments only from this week (Monday to Sunday)
|
||||
const appointmentsThisWeek = appointments.filter((appointment) => {
|
||||
if (!appointment.date) return false;
|
||||
|
||||
const date = new Date(appointment.date);
|
||||
// Reset time to compare just the date
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date >= monday && date <= sunday;
|
||||
});
|
||||
|
||||
// Count appointments by day for current week
|
||||
appointmentsThisWeek.forEach((appointment) => {
|
||||
const date = new Date(appointment.date);
|
||||
const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, ...
|
||||
const dayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Monday=0, Sunday=6
|
||||
if (countsByDay[dayIndex]) {
|
||||
countsByDay[dayIndex].count += 1;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Appointments by Day
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Distribution of appointments throughout the week
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={countsByDay}
|
||||
margin={{ top: 5, right: 5, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${value} appointments`, "Count"]}
|
||||
labelFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#2563eb" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
70
apps/Frontend/src/components/analytics/new-patients.tsx
Executable file
70
apps/Frontend/src/components/analytics/new-patients.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
interface NewPatientsProps {
|
||||
patients: any[];
|
||||
}
|
||||
|
||||
export function NewPatients({ patients }: NewPatientsProps) {
|
||||
// Get months for the chart
|
||||
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
// Process patient data by registration month
|
||||
const patientsByMonth = months.map(month => ({ name: month, count: 0 }));
|
||||
|
||||
// Count new patients by month
|
||||
patients.forEach(patient => {
|
||||
const createdDate = new Date(patient.createdAt);
|
||||
const monthIndex = createdDate.getMonth();
|
||||
if (patientsByMonth[monthIndex]) {
|
||||
patientsByMonth[monthIndex].count += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Add some sample data for visual effect if no patients
|
||||
if (patients.length === 0) {
|
||||
// Sample data pattern similar to the screenshot
|
||||
const sampleData = [17, 12, 22, 16, 15, 17, 22, 28, 20, 16];
|
||||
sampleData.forEach((value, index) => {
|
||||
if (index < patientsByMonth.length ) {
|
||||
if (patientsByMonth[index]) {
|
||||
patientsByMonth[index].count = value;
|
||||
}}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">New Patients</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Monthly trend of new patient registrations</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={patientsByMonth}
|
||||
margin={{ top: 5, right: 5, left: 0, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="name" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${value} patients`, "Count"]}
|
||||
labelFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, strokeWidth: 2 }}
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, Plus, Save, X } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
|
||||
import {
|
||||
CODE_MAP,
|
||||
getPriceForCodeWithAgeFromMap,
|
||||
} from "@/utils/procedureCombosMapping";
|
||||
import { Patient, AppointmentProcedure } from "@repo/db/types";
|
||||
import { useLocation } from "wouter";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import {
|
||||
DirectComboButtons,
|
||||
RegularComboButtons,
|
||||
} from "@/components/procedure/procedure-combo-buttons";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appointmentId: number;
|
||||
patientId: number;
|
||||
patient: Patient;
|
||||
}
|
||||
|
||||
export function AppointmentProceduresDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointmentId,
|
||||
patientId,
|
||||
patient,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// -----------------------------
|
||||
// state for manual add
|
||||
// -----------------------------
|
||||
const [manualCode, setManualCode] = useState("");
|
||||
const [manualLabel, setManualLabel] = useState("");
|
||||
const [manualFee, setManualFee] = useState("");
|
||||
const [manualTooth, setManualTooth] = useState("");
|
||||
const [manualSurface, setManualSurface] = useState("");
|
||||
|
||||
// -----------------------------
|
||||
// state for inline edit
|
||||
// -----------------------------
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editRow, setEditRow] = useState<Partial<AppointmentProcedure>>({});
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
|
||||
// for redirection to claim submission
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// -----------------------------
|
||||
// fetch procedures
|
||||
// -----------------------------
|
||||
const { data: procedures = [], isLoading } = useQuery<AppointmentProcedure[]>(
|
||||
{
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/appointment-procedures/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load procedures");
|
||||
return res.json();
|
||||
},
|
||||
enabled: open && !!appointmentId,
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// mutations
|
||||
// -----------------------------
|
||||
const addManualMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: manualCode,
|
||||
procedureLabel: manualLabel || null,
|
||||
fee: manualFee ? Number(manualFee) : null,
|
||||
toothNumber: manualTooth || null,
|
||||
toothSurface: manualSurface || null,
|
||||
source: "MANUAL",
|
||||
};
|
||||
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures",
|
||||
payload,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add procedure");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Procedure added" });
|
||||
setManualCode("");
|
||||
setManualLabel("");
|
||||
setManualFee("");
|
||||
setManualTooth("");
|
||||
setManualSurface("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to add procedure",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddMutation = useMutation({
|
||||
mutationFn: async (rows: any[]) => {
|
||||
const res = await apiRequest(
|
||||
"POST",
|
||||
"/api/appointment-procedures/bulk",
|
||||
rows,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to add combo procedures");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Combo added" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/${id}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/appointment-procedures/clear/${appointmentId}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to clear procedures");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "All procedures cleared" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
setClearAllOpen(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: err.message ?? "Failed to clear procedures",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!editingId) return;
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/appointment-procedures/${editingId}`,
|
||||
editRow,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to update");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Updated" });
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appointment-procedures", appointmentId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// handlers
|
||||
// -----------------------------
|
||||
const handleAddCombo = (comboKey: string) => {
|
||||
const combo = PROCEDURE_COMBOS[comboKey];
|
||||
if (!combo || !patient?.dateOfBirth) return;
|
||||
|
||||
const serviceDate = new Date();
|
||||
const dob = patient.dateOfBirth;
|
||||
|
||||
const age = (() => {
|
||||
const birth = new Date(dob);
|
||||
const ref = new Date(serviceDate);
|
||||
let a = ref.getFullYear() - birth.getFullYear();
|
||||
const hadBirthday =
|
||||
ref.getMonth() > birth.getMonth() ||
|
||||
(ref.getMonth() === birth.getMonth() &&
|
||||
ref.getDate() >= birth.getDate());
|
||||
if (!hadBirthday) a -= 1;
|
||||
return a;
|
||||
})();
|
||||
|
||||
const rows = combo.codes.map((code: string, idx: number) => {
|
||||
const priceDecimal = getPriceForCodeWithAgeFromMap(CODE_MAP, code, age);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
patientId,
|
||||
procedureCode: code,
|
||||
procedureLabel: combo.label,
|
||||
fee: priceDecimal.toNumber(),
|
||||
source: "COMBO",
|
||||
comboKey: comboKey,
|
||||
toothNumber: combo.toothNumbers?.[idx] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
bulkAddMutation.mutate(rows);
|
||||
};
|
||||
|
||||
const startEdit = (row: AppointmentProcedure) => {
|
||||
if (!row.id) return;
|
||||
|
||||
setEditingId(row.id);
|
||||
setEditRow({
|
||||
procedureCode: row.procedureCode,
|
||||
procedureLabel: row.procedureLabel,
|
||||
fee: row.fee,
|
||||
toothNumber: row.toothNumber,
|
||||
toothSurface: row.toothSurface,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditRow({});
|
||||
};
|
||||
|
||||
const handleDirectClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleManualClaim = () => {
|
||||
setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// UI
|
||||
// -----------------------------
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-6xl max-h-[90vh] overflow-y-auto pointer-events-none"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (clearAllOpen) {
|
||||
e.preventDefault(); // block only when delete dialog is open
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Appointment Procedures
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ================= COMBOS ================= */}
|
||||
<div className="space-y-8 pointer-events-auto">
|
||||
<DirectComboButtons
|
||||
onDirectCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RegularComboButtons
|
||||
onRegularCombo={(comboKey) => {
|
||||
handleAddCombo(comboKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ================= MANUAL ADD ================= */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={manualCode}
|
||||
onChange={(e) => setManualCode(e.target.value)}
|
||||
placeholder="D0120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={manualLabel}
|
||||
onChange={(e) => setManualLabel(e.target.value)}
|
||||
placeholder="Exam"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Fee</Label>
|
||||
<Input
|
||||
value={manualFee}
|
||||
onChange={(e) => setManualFee(e.target.value)}
|
||||
placeholder="100"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tooth</Label>
|
||||
<Input
|
||||
value={manualTooth}
|
||||
onChange={(e) => setManualTooth(e.target.value)}
|
||||
placeholder="14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Surface</Label>
|
||||
<Input
|
||||
value={manualSurface}
|
||||
onChange={(e) => setManualSurface(e.target.value)}
|
||||
placeholder="MO"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addManualMutation.mutate()}
|
||||
disabled={!manualCode || addManualMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Procedure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= LIST ================= */}
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Selected Procedures</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!procedures.length}
|
||||
onClick={() => setClearAllOpen(true)}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg divide-y bg-white">
|
||||
{/* ===== TABLE HEADER ===== */}
|
||||
<div className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted/40">
|
||||
<div>Code</div>
|
||||
<div>Label</div>
|
||||
<div>Fee</div>
|
||||
<div>Tooth</div>
|
||||
<div>Surface</div>
|
||||
<div className="text-center">Edit</div>
|
||||
<div className="text-center">Delete</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && procedures.length === 0 && (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No procedures added
|
||||
</div>
|
||||
)}
|
||||
|
||||
{procedures.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition"
|
||||
>
|
||||
{editingId === p.id ? (
|
||||
<>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={editRow.procedureCode ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureCode: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={editRow.procedureLabel ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
procedureLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[90px]"
|
||||
value={
|
||||
editRow.fee !== undefined && editRow.fee !== null
|
||||
? String(editRow.fee)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditRow({ ...editRow, fee: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-[80px]"
|
||||
value={editRow.toothSurface ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditRow({
|
||||
...editRow,
|
||||
toothSurface: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => updateMutation.mutate()}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button size="icon" variant="ghost" onClick={cancelEdit}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-[90px] font-medium">
|
||||
{p.procedureCode}
|
||||
</div>
|
||||
<div className="flex-1 text-muted-foreground">
|
||||
{p.procedureLabel}
|
||||
</div>
|
||||
<div className="w-[90px]">
|
||||
{p.fee !== null && p.fee !== undefined
|
||||
? String(p.fee)
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
<div className="w-[80px]">{p.toothNumber}</div>
|
||||
<div className="w-[80px]">{p.toothSurface}</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => startEdit(p)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMutation.mutate(p.id!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= FOOTER ================= */}
|
||||
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={!procedures.length}
|
||||
onClick={handleDirectClaim}
|
||||
>
|
||||
Direct Claim
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
||||
disabled={!procedures.length}
|
||||
onClick={handleManualClaim}
|
||||
>
|
||||
Manual Claim
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={clearAllOpen}
|
||||
entityName="all procedures for this appointment"
|
||||
onCancel={() => setClearAllOpen(false)}
|
||||
onConfirm={() => {
|
||||
setClearAllOpen(false);
|
||||
clearAllMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
54
apps/Frontend/src/components/appointments/add-appointment-modal.tsx
Executable file
54
apps/Frontend/src/components/appointments/add-appointment-modal.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AppointmentForm } from "./appointment-form";
|
||||
import {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
|
||||
interface AddAppointmentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
isLoading: boolean;
|
||||
appointment?: Appointment;
|
||||
}
|
||||
|
||||
export function AddAppointmentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
isLoading,
|
||||
appointment,
|
||||
}: AddAppointmentModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{appointment ? "Edit Appointment" : "Add New Appointment"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-1">
|
||||
<AppointmentForm
|
||||
appointment={appointment}
|
||||
onSubmit={(data) => {
|
||||
onSubmit(data);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
onDelete={onDelete}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
763
apps/Frontend/src/components/appointments/appointment-form.tsx
Executable file
763
apps/Frontend/src/components/appointments/appointment-form.tsx
Executable file
@@ -0,0 +1,763 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format } from "date-fns";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
Appointment,
|
||||
InsertAppointment,
|
||||
insertAppointmentSchema,
|
||||
Patient,
|
||||
Staff,
|
||||
UpdateAppointment,
|
||||
} from "@repo/db/types";
|
||||
import { DateInputField } from "@/components/ui/dateInputField";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import {
|
||||
PatientSearch,
|
||||
SearchCriteria,
|
||||
} from "@/components/patients/patient-search";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
interface AppointmentFormProps {
|
||||
appointment?: Appointment;
|
||||
onSubmit: (data: InsertAppointment | UpdateAppointment) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function AppointmentForm({
|
||||
appointment,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
isLoading = false,
|
||||
}: AppointmentFormProps) {
|
||||
const { user } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [prefillPatient, setPrefillPatient] = useState<Patient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50); // small delay ensures content is mounted
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const { data: staffMembersRaw = [] as Staff[] } = useQuery<Staff[]>({
|
||||
queryKey: ["/api/staffs/"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/staffs/");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
"Dr. Kai Gao": "bg-blue-600",
|
||||
"Dr. Jane Smith": "bg-emerald-600",
|
||||
};
|
||||
|
||||
const staffMembers = staffMembersRaw.map((staff) => ({
|
||||
...staff,
|
||||
color: colorMap[staff.name] || "bg-gray-400",
|
||||
}));
|
||||
|
||||
// Get the stored data from session storage
|
||||
const storedDataString = sessionStorage.getItem("newAppointmentData");
|
||||
let parsedStoredData = null;
|
||||
|
||||
// Try to parse it if it exists
|
||||
if (storedDataString) {
|
||||
try {
|
||||
parsedStoredData = JSON.parse(storedDataString);
|
||||
} catch (error) {
|
||||
console.error("Error parsing stored appointment data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Format the date and times for the form
|
||||
const defaultValues: Partial<Appointment> = appointment
|
||||
? {
|
||||
userId: user?.id,
|
||||
patientId: appointment.patientId,
|
||||
title: appointment.title,
|
||||
date: parseLocalDate(appointment.date),
|
||||
startTime: appointment.startTime || "09:00", // Default "09:00"
|
||||
endTime: appointment.endTime || "09:30", // Default "09:30"
|
||||
type: appointment.type,
|
||||
notes: appointment.notes || "",
|
||||
status: appointment.status || "scheduled",
|
||||
staffId:
|
||||
typeof appointment.staffId === "number"
|
||||
? appointment.staffId
|
||||
: undefined,
|
||||
}
|
||||
: parsedStoredData
|
||||
? {
|
||||
userId: user?.id,
|
||||
patientId: Number(parsedStoredData.patientId),
|
||||
date: parsedStoredData.date
|
||||
? parseLocalDate(parsedStoredData.date)
|
||||
: parseLocalDate(new Date()),
|
||||
title: parsedStoredData.title || "",
|
||||
startTime: parsedStoredData.startTime,
|
||||
endTime: parsedStoredData.endTime,
|
||||
type: parsedStoredData.type || "checkup",
|
||||
status: parsedStoredData.status || "scheduled",
|
||||
notes: parsedStoredData.notes || "",
|
||||
staffId:
|
||||
typeof parsedStoredData.staff === "number"
|
||||
? parsedStoredData.staff
|
||||
: (staffMembers?.[0]?.id ?? undefined),
|
||||
}
|
||||
: {
|
||||
userId: user?.id ?? 0,
|
||||
date: new Date(),
|
||||
title: "",
|
||||
startTime: "09:00",
|
||||
endTime: "09:30",
|
||||
type: "checkup",
|
||||
status: "scheduled",
|
||||
staffId: staffMembers?.[0]?.id ?? undefined,
|
||||
};
|
||||
|
||||
const form = useForm<InsertAppointment>({
|
||||
resolver: zodResolver(insertAppointmentSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// PATIENT SEARCH (reuse PatientSearch)
|
||||
// -----------------------------
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
|
||||
// search criteria state (reused from patient page)
|
||||
const [searchCriteria, setSearchCriteria] = useState<SearchCriteria | null>(
|
||||
null
|
||||
);
|
||||
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||
|
||||
// debounce search criteria so we don't hammer the backend
|
||||
const [debouncedSearchCriteria] = useDebounce(searchCriteria, 300);
|
||||
|
||||
const limit = 50; // dropdown size
|
||||
const offset = 0; // always first page for dropdown
|
||||
|
||||
// compute key used in patient page: recent or trimmed term
|
||||
const searchKeyPart = useMemo(
|
||||
() => debouncedSearchCriteria?.searchTerm?.trim() || "recent",
|
||||
[debouncedSearchCriteria]
|
||||
);
|
||||
|
||||
// Query function mirrors PatientTable logic (so backend contract is identical)
|
||||
const queryFn = async (): Promise<Patient[]> => {
|
||||
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim();
|
||||
const isSearch = !!trimmedTerm && trimmedTerm.length > 0;
|
||||
const rawSearchBy = debouncedSearchCriteria?.searchBy || "name";
|
||||
const validSearchKeys = [
|
||||
"name",
|
||||
"phone",
|
||||
"insuranceId",
|
||||
"gender",
|
||||
"dob",
|
||||
"all",
|
||||
];
|
||||
const searchKey = validSearchKeys.includes(rawSearchBy)
|
||||
? rawSearchBy
|
||||
: "name";
|
||||
|
||||
let url: string;
|
||||
if (isSearch) {
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
if (searchKey === "all") {
|
||||
searchParams.set("term", trimmedTerm!);
|
||||
} else {
|
||||
searchParams.set(searchKey, trimmedTerm!);
|
||||
}
|
||||
|
||||
url = `/api/patients/search?${searchParams.toString()}`;
|
||||
} else {
|
||||
url = `/api/patients/recent?limit=${limit}&offset=${offset}`;
|
||||
}
|
||||
|
||||
const res = await apiRequest("GET", url);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to fetch patients" }));
|
||||
throw new Error(err.message || "Failed to fetch patients");
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
// Expect payload to be { patients: Patient[], totalCount: number } or just an array.
|
||||
// Normalize: if payload.patients exists, return it; otherwise assume array of patients.
|
||||
return Array.isArray(payload) ? payload : (payload.patients ?? []);
|
||||
};
|
||||
|
||||
const {
|
||||
data: patients = [],
|
||||
isFetching: isFetchingPatients,
|
||||
refetch: refetchPatients,
|
||||
} = useQuery<Patient[], Error>({
|
||||
queryKey: ["patients-dropdown", searchKeyPart],
|
||||
queryFn,
|
||||
enabled: selectOpen || !!debouncedSearchCriteria?.searchTerm,
|
||||
});
|
||||
|
||||
// If select opened and no patients loaded, fetch
|
||||
useEffect(() => {
|
||||
if (selectOpen && (!patients || patients.length === 0)) {
|
||||
refetchPatients();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectOpen]);
|
||||
|
||||
// Force form field values to update and clean up storage
|
||||
useEffect(() => {
|
||||
if (!parsedStoredData) return;
|
||||
|
||||
// set times/staff/date as before
|
||||
if (parsedStoredData.startTime)
|
||||
form.setValue("startTime", parsedStoredData.startTime);
|
||||
if (parsedStoredData.endTime)
|
||||
form.setValue("endTime", parsedStoredData.endTime);
|
||||
if (parsedStoredData.staff)
|
||||
form.setValue("staffId", parsedStoredData.staff);
|
||||
if (parsedStoredData.date) {
|
||||
form.setValue("date", parseLocalDate(parsedStoredData.date));
|
||||
}
|
||||
|
||||
// ---- patient prefill: check main cache, else fetch once ----
|
||||
if (parsedStoredData.patientId) {
|
||||
const pid = Number(parsedStoredData.patientId);
|
||||
if (!Number.isNaN(pid)) {
|
||||
// ensure the form value is set
|
||||
form.setValue("patientId", pid);
|
||||
|
||||
// fetch single patient record (preferred)
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/patients/${pid}`);
|
||||
if (res.ok) {
|
||||
const patientRecord = await res.json();
|
||||
setPrefillPatient(patientRecord);
|
||||
} else {
|
||||
// non-OK response: show toast with status / message
|
||||
let msg = `Failed to load patient (status ${res.status})`;
|
||||
try {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body && body.message) msg = body.message;
|
||||
} catch {}
|
||||
toast({
|
||||
title: "Could not load patient",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error fetching patient",
|
||||
description:
|
||||
(err as Error)?.message ||
|
||||
"An unknown error occurred while fetching patient details.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
// remove the one-time transport
|
||||
sessionStorage.removeItem("newAppointmentData");
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else {
|
||||
// no patientId in storage — still remove to avoid stale state
|
||||
sessionStorage.removeItem("newAppointmentData");
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
// When editing an appointment, ensure we prefill the patient so SelectValue can render
|
||||
useEffect(() => {
|
||||
if (!appointment?.patientId) return;
|
||||
|
||||
const pid = Number(appointment.patientId);
|
||||
if (Number.isNaN(pid)) return;
|
||||
|
||||
// set form value immediately so the select has a value
|
||||
form.setValue("patientId", pid);
|
||||
|
||||
// fetch the single patient record and set prefill
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiRequest("GET", `/api/patients/${pid}`);
|
||||
if (res.ok) {
|
||||
const patientRecord = await res.json();
|
||||
setPrefillPatient(patientRecord);
|
||||
} else {
|
||||
let msg = `Failed to load patient (status ${res.status})`;
|
||||
try {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body && body.message) msg = body.message;
|
||||
} catch {}
|
||||
toast({
|
||||
title: "Could not load patient",
|
||||
description: msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error fetching patient",
|
||||
description:
|
||||
(err as Error)?.message ||
|
||||
"An unknown error occurred while fetching patient details.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
})();
|
||||
// note: we intentionally do NOT remove prefillPatientd here; it will be cleared when dropdown opens and main list contains the patient
|
||||
}, [appointment?.patientId]);
|
||||
|
||||
const handleSubmit = (data: InsertAppointment) => {
|
||||
// Make sure patientId is a number
|
||||
const patientId =
|
||||
typeof data.patientId === "string"
|
||||
? parseInt(data.patientId, 10)
|
||||
: data.patientId;
|
||||
|
||||
// Auto-create title if it's empty
|
||||
let title = data.title;
|
||||
if (!title || title.trim() === "") {
|
||||
// Format: "April 19" - just the date
|
||||
title = format(data.date, "MMMM d");
|
||||
}
|
||||
|
||||
let notes = data.notes || "";
|
||||
|
||||
const selectedStaff =
|
||||
staffMembers.find((staff) => staff.id?.toString() === data.staffId) ||
|
||||
staffMembers[0];
|
||||
|
||||
if (!selectedStaff) {
|
||||
console.error("No staff selected and no available staff in the list");
|
||||
return; // Handle this case as well
|
||||
}
|
||||
|
||||
// If there's no staff information in the notes, add it
|
||||
if (!notes.includes("Appointment with")) {
|
||||
notes = notes
|
||||
? `${notes}\nAppointment with ${selectedStaff?.name}`
|
||||
: `Appointment with ${selectedStaff?.name}`;
|
||||
}
|
||||
|
||||
const formattedDate = formatLocalDate(data.date);
|
||||
|
||||
onSubmit({
|
||||
...data,
|
||||
userId: Number(user?.id),
|
||||
title,
|
||||
notes,
|
||||
patientId,
|
||||
date: formattedDate,
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-container">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
(data) => {
|
||||
handleSubmit(data);
|
||||
},
|
||||
(errors) => {
|
||||
console.error("Validation failed:", errors);
|
||||
}
|
||||
)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="patientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Patient</FormLabel>
|
||||
|
||||
<Select
|
||||
disabled={isLoading}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setSelectOpen(open);
|
||||
if (!open) {
|
||||
// reset transient search state when the dropdown closes
|
||||
setSearchCriteria(null);
|
||||
setIsSearchActive(false);
|
||||
|
||||
// Remove transient prefill if the main cached list contains it now
|
||||
if (
|
||||
prefillPatient &&
|
||||
patients &&
|
||||
patients.some(
|
||||
(p) => Number(p.id) === Number(prefillPatient.id)
|
||||
)
|
||||
) {
|
||||
setPrefillPatient(null);
|
||||
}
|
||||
} else {
|
||||
// when opened, ensure initial results
|
||||
if (!patients || patients.length === 0) refetchPatients();
|
||||
}
|
||||
}}
|
||||
value={
|
||||
field.value == null || // null or undefined
|
||||
(typeof field.value === "number" &&
|
||||
!Number.isFinite(field.value)) || // NaN/Infinity
|
||||
(typeof field.value === "string" &&
|
||||
field.value.trim() === "") || // empty string
|
||||
field.value === "NaN" // defensive check
|
||||
? ""
|
||||
: String(field.value)
|
||||
}
|
||||
onValueChange={(val) =>
|
||||
field.onChange(val === "" ? undefined : Number(val))
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a patient" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{/* Reuse full PatientSearch UI inside dropdown — callbacks update the query */}
|
||||
<div className="p-2" onKeyDown={(e) => e.stopPropagation()}>
|
||||
<PatientSearch
|
||||
onSearch={(criteria) => {
|
||||
setSearchCriteria(criteria);
|
||||
setIsSearchActive(true);
|
||||
}}
|
||||
onClearSearch={() => {
|
||||
setSearchCriteria({
|
||||
searchTerm: "",
|
||||
searchBy: "name",
|
||||
});
|
||||
setIsSearchActive(false);
|
||||
}}
|
||||
isSearchActive={isSearchActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prefill patient only if main list does not already include them */}
|
||||
{prefillPatient &&
|
||||
!patients.some(
|
||||
(p) => Number(p.id) === Number(prefillPatient.id)
|
||||
) && (
|
||||
<SelectItem
|
||||
key={`prefill-${prefillPatient.id}`}
|
||||
value={prefillPatient.id?.toString() ?? ""}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">
|
||||
{prefillPatient.firstName}{" "}
|
||||
{prefillPatient.lastName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
DOB:{" "}
|
||||
{prefillPatient.dateOfBirth
|
||||
? new Date(
|
||||
prefillPatient.dateOfBirth
|
||||
).toLocaleDateString()
|
||||
: ""}{" "}
|
||||
• {prefillPatient.phone ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
<div className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/30">
|
||||
{isFetchingPatients ? (
|
||||
<div className="p-2 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : patients && patients.length > 0 ? (
|
||||
patients.map((patient) => (
|
||||
<SelectItem
|
||||
key={patient.id}
|
||||
value={patient.id?.toString() ?? ""}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">
|
||||
{patient.firstName} {patient.lastName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
DOB:{" "}
|
||||
{new Date(
|
||||
patient.dateOfBirth
|
||||
).toLocaleDateString()}{" "}
|
||||
• {patient.phone}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2 text-muted-foreground text-sm">
|
||||
No patients found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Appointment Title{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
(optional)
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Leave blank to auto-fill with date"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DateInputField control={form.control} name="date" label="Date" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startTime"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Start Time</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="09:00"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endTime"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>End Time</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="09:30"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Appointment Type</FormLabel>
|
||||
<Select
|
||||
disabled={isLoading}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="checkup">Checkup</SelectItem>
|
||||
<SelectItem value="cleaning">Cleaning</SelectItem>
|
||||
<SelectItem value="filling">Filling</SelectItem>
|
||||
<SelectItem value="extraction">Extraction</SelectItem>
|
||||
<SelectItem value="root-canal">Root Canal</SelectItem>
|
||||
<SelectItem value="crown">Crown</SelectItem>
|
||||
<SelectItem value="dentures">Dentures</SelectItem>
|
||||
<SelectItem value="consultation">Consultation</SelectItem>
|
||||
<SelectItem value="emergency">Emergency</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select
|
||||
disabled={isLoading}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">Scheduled</SelectItem>
|
||||
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
<SelectItem value="no-show">No Show</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="staffId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Doctor/Hygienist</FormLabel>
|
||||
<Select
|
||||
disabled={isLoading}
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
value={field.value ? String(field.value) : undefined}
|
||||
defaultValue={field.value ? String(field.value) : undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select staff member" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{staffMembers.map((staff) => (
|
||||
<SelectItem
|
||||
key={staff.id}
|
||||
value={staff.id?.toString() || ""}
|
||||
>
|
||||
{staff.name} ({staff.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter any notes about the appointment"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
className="min-h-24"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{appointment ? "Update Appointment" : "Create Appointment"}
|
||||
</Button>
|
||||
|
||||
{appointment?.id && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenChange?.(false); // 👈 Close the modal first
|
||||
|
||||
setTimeout(() => {
|
||||
onDelete?.(appointment.id!);
|
||||
}, 300); // 300ms is safe for most animations
|
||||
}}
|
||||
className="bg-red-600 text-white w-full rounded hover:bg-red-700"
|
||||
>
|
||||
Delete Appointment
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/Frontend/src/components/appointments/patient-status-badge.tsx
Executable file
55
apps/Frontend/src/components/appointments/patient-status-badge.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PatientStatus } from "@repo/db/types";
|
||||
|
||||
export function PatientStatusBadge({
|
||||
status,
|
||||
className = "",
|
||||
size = 10,
|
||||
}: {
|
||||
status: PatientStatus;
|
||||
className?: string;
|
||||
size?: number; // px
|
||||
}) {
|
||||
const { bg, label } = getVisuals(status);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={`Patient status: ${label}`}
|
||||
className={`inline-block rounded-full ring-2 ring-white shadow ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: bg,
|
||||
position: "absolute",
|
||||
top: "-6px", // stick out above card
|
||||
right: "-6px", // stick out right
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="px-2 py-1 text-xs">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getVisuals(status: PatientStatus): { label: string; bg: string } {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green)
|
||||
case "INACTIVE":
|
||||
return { label: "Inactive", bg: "#DC2626" }; // ALERT RED (distinct from card red)
|
||||
default:
|
||||
return { label: "Unknown", bg: "#6B7280" }; // solid gray
|
||||
}
|
||||
}
|
||||
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
148
apps/Frontend/src/components/claims/claim-document-upload-modal.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, FilePlus } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
MultipleFileUploadZone,
|
||||
MultipleFileUploadZoneHandle,
|
||||
} from "../file-upload/multiple-file-upload-zone";
|
||||
|
||||
export default function ClaimDocumentsUploadMultiple() {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Internal configuration
|
||||
const MAX_FILES = 10;
|
||||
const ACCEPTED_FILE_TYPES =
|
||||
"application/pdf,image/jpeg,image/jpg,image/png,image/webp";
|
||||
const TITLE = "Upload Claim Document(s)";
|
||||
const DESCRIPTION =
|
||||
"You can upload up to 10 files. Allowed types: PDF, JPG, PNG, WEBP.";
|
||||
|
||||
// Zone ref + minimal UI state (parent does not own files)
|
||||
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
|
||||
const [filesForUI, setFilesForUI] = useState<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false); // forwarded to upload zone
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// Called by MultipleFileUploadZone when its internal list changes (UI-only)
|
||||
const handleZoneFilesChange = useCallback((files: File[]) => {
|
||||
setFilesForUI(files);
|
||||
}, []);
|
||||
|
||||
// Dummy save (simulate async). Replace with real API call when needed.
|
||||
const handleSave = useCallback(async (files: File[]) => {
|
||||
// simulate network / processing time
|
||||
await new Promise((res) => setTimeout(res, 800));
|
||||
console.log(
|
||||
"handleSave called for files:",
|
||||
files.map((f) => f.name)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Extract handler — reads files from the zone via ref and calls handleSave
|
||||
const handleExtract = useCallback(async () => {
|
||||
const files = uploadZoneRef.current?.getFiles() ?? [];
|
||||
|
||||
if (files.length === 0) {
|
||||
toast({
|
||||
title: "No files",
|
||||
description: "Please upload at least one file before extracting.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExtracting) return;
|
||||
setIsExtracting(true);
|
||||
|
||||
try {
|
||||
await handleSave(files);
|
||||
|
||||
toast({
|
||||
title: "Extraction started",
|
||||
description: `Processing ${files.length} file(s).`,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// we intentionally leave files intact in the zone after extraction
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Extraction failed",
|
||||
description:
|
||||
"There was an error starting extraction. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("extract error", err);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [handleSave, isExtracting, toast]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{TITLE}</CardTitle>
|
||||
<CardDescription>{DESCRIPTION}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* File Upload Section */}
|
||||
<div className="bg-gray-100 p-4 rounded-md space-y-4">
|
||||
<MultipleFileUploadZone
|
||||
ref={uploadZoneRef}
|
||||
onFilesChange={handleZoneFilesChange}
|
||||
isUploading={isUploading}
|
||||
acceptedFileTypes={ACCEPTED_FILE_TYPES}
|
||||
maxFiles={MAX_FILES}
|
||||
/>
|
||||
|
||||
{/* Show list of files received from the upload zone */}
|
||||
{filesForUI.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Uploaded ({filesForUI.length}/{MAX_FILES})
|
||||
</p>
|
||||
<ul className="text-sm text-gray-700 list-disc ml-6 max-h-40 overflow-auto">
|
||||
{filesForUI.map((file, index) => (
|
||||
<li key={index} className="truncate" title={file.name}>
|
||||
{file.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
className="w-full h-12 gap-2"
|
||||
disabled={filesForUI.length === 0 || isExtracting}
|
||||
onClick={handleExtract}
|
||||
>
|
||||
{isExtracting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilePlus className="h-4 w-4" />
|
||||
Extract Claim Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
313
apps/Frontend/src/components/claims/claim-edit-modal.tsx
Executable file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import React, { useState } from "react";
|
||||
import { ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimEditModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onSave: (updatedClaim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimEditModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onSave,
|
||||
}: ClaimEditModalProps) {
|
||||
const [status, setStatus] = useState<ClaimStatus>(
|
||||
claim?.status ?? ("PENDING" as ClaimStatus)
|
||||
);
|
||||
|
||||
if (!claim) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedClaim: ClaimWithServiceLines = {
|
||||
...claim,
|
||||
status,
|
||||
};
|
||||
|
||||
onSave(updatedClaim);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Claim Status</DialogTitle>
|
||||
<DialogDescription>Update the status of the claim.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Patient Details */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
|
||||
{claim.patientName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
|
||||
<p className="text-gray-500">
|
||||
Claim ID: {claim.id?.toString().padStart(4, "0")}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Claim No: {claim.claimNumber || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Basic Information</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p>
|
||||
<span className="text-gray-500">Date of Birth:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.dateOfBirth)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Service Date:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.serviceDate)}
|
||||
</p>
|
||||
<div>
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => setStatus(value as ClaimStatus)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="REVIEW">Review</SelectItem>
|
||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Insurance Details</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p>
|
||||
<span className="text-gray-500">Claim Number:</span>{" "}
|
||||
{claim.claimNumber || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Insurance Provider:</span>{" "}
|
||||
{claim.insuranceProvider || "N/A"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Member ID:</span>{" "}
|
||||
{claim.memberId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Remarks:</span>{" "}
|
||||
{claim.remarks || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">Timestamps</h4>
|
||||
<p>
|
||||
<span className="text-gray-500">Created At:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Updated At:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Staff Info */}
|
||||
{claim.staff && (
|
||||
<div className="space-y-2 pt-4">
|
||||
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
|
||||
<p>
|
||||
<span className="text-gray-500">Name:</span> {claim.staff.name}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Role:</span> {claim.staff.role}
|
||||
</p>
|
||||
{claim.staff.email && (
|
||||
<p>
|
||||
<span className="text-gray-500">Email:</span>{" "}
|
||||
{claim.staff.email}
|
||||
</p>
|
||||
)}
|
||||
{claim.staff.phone && (
|
||||
<p>
|
||||
<span className="text-gray-500">Phone:</span>{" "}
|
||||
{claim.staff.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Lines */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
|
||||
<div className="mt-2 space-y-3">
|
||||
{claim.serviceLines.length > 0 ? (
|
||||
<>
|
||||
{claim.serviceLines.map((line) => (
|
||||
<div
|
||||
key={line.id}
|
||||
className="border p-3 rounded-md bg-gray-50"
|
||||
>
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Code:</span>{" "}
|
||||
{line.procedureCode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Date:</span>{" "}
|
||||
{formatDateToHumanReadable(line.procedureDate)}
|
||||
</p>
|
||||
{line.quad && (
|
||||
<p>
|
||||
<span className="text-gray-500">Quad:</span>{" "}
|
||||
{line.quad}
|
||||
</p>
|
||||
)}
|
||||
{line.arch && (
|
||||
<p>
|
||||
<span className="text-gray-500">Arch:</span>{" "}
|
||||
{line.arch}
|
||||
</p>
|
||||
)}
|
||||
{line.toothNumber && (
|
||||
<p>
|
||||
<span className="text-gray-500">Tooth Number:</span>{" "}
|
||||
{line.toothNumber}
|
||||
</p>
|
||||
)}
|
||||
{line.toothSurface && (
|
||||
<p>
|
||||
<span className="text-gray-500">Tooth Surface:</span>{" "}
|
||||
{line.toothSurface}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-gray-500">Billed Amount:</span> $
|
||||
{Number(line.totalBilled).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
|
||||
Total Billed Amount: $
|
||||
{claim.serviceLines
|
||||
.reduce((total, line) => {
|
||||
const billed = line.totalBilled
|
||||
? parseFloat(line.totalBilled as any)
|
||||
: 0;
|
||||
return total + billed;
|
||||
}, 0)
|
||||
.toFixed(2)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500">No service lines available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing Teeth */}
|
||||
<div className="space-y-2 pt-4">
|
||||
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
|
||||
|
||||
<p>
|
||||
<span className="text-gray-500">Status:</span>{" "}
|
||||
{toStatusLabel((claim as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth((claim as any).missingTeeth);
|
||||
const { permanent, primary } = splitTeeth(map);
|
||||
const hasAny = permanent.length > 0 || primary.length > 0;
|
||||
|
||||
if (!hasAny) {
|
||||
return (
|
||||
<p className="text-gray-500">
|
||||
No specific teeth marked as missing.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-3">
|
||||
{permanent.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">
|
||||
Permanent
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permanent.map((t) => (
|
||||
<ToothChip key={t.name} name={t.name} v={t.v} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{primary.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">
|
||||
Primary
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{primary.map((t) => (
|
||||
<ToothChip key={t.name} name={t.name} v={t.v} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(claim as any).missingTeethStatus === "endentulous" && (
|
||||
<p className="text-sm text-gray-700">Patient is edentulous.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
1504
apps/Frontend/src/components/claims/claim-form.tsx
Executable file
File diff suppressed because it is too large
Load Diff
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
369
apps/Frontend/src/components/claims/claim-view-modal.tsx
Executable file
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { ClaimFileMeta, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { FileText, Paperclip } from "lucide-react";
|
||||
import {
|
||||
safeParseMissingTeeth,
|
||||
splitTeeth,
|
||||
ToothChip,
|
||||
toStatusLabel,
|
||||
} from "./tooth-ui";
|
||||
|
||||
type ClaimViewModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
claim: ClaimWithServiceLines | null;
|
||||
onEditClaim: (claim: ClaimWithServiceLines) => void;
|
||||
};
|
||||
|
||||
export default function ClaimViewModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onClose,
|
||||
claim,
|
||||
onEditClaim,
|
||||
}: ClaimViewModalProps) {
|
||||
// Normalizer: supports both ClaimFile[] and nested-create shape { create: ClaimFile[] }
|
||||
const getClaimFilesArray = (
|
||||
c: ClaimWithServiceLines | null
|
||||
): ClaimFileMeta[] => {
|
||||
if (!c) return [];
|
||||
|
||||
// If it's already a plain array (runtime from Prisma include), return it
|
||||
const maybeFiles = (c as any).claimFiles;
|
||||
if (!maybeFiles) return [];
|
||||
|
||||
if (Array.isArray(maybeFiles)) {
|
||||
// ensure each item has filename field (best-effort)
|
||||
return maybeFiles.map((f: any) => ({
|
||||
id: f?.id,
|
||||
filename: String(f?.filename ?? ""),
|
||||
mimeType: f?.mimeType ?? f?.mime ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Nested-create shape: { create: [...] }
|
||||
if (maybeFiles && Array.isArray(maybeFiles.create)) {
|
||||
return maybeFiles.create.map((f: any) => ({
|
||||
id: f?.id,
|
||||
filename: String(f?.filename ?? ""),
|
||||
mimeType: f?.mimeType ?? f?.mime ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// No recognized shape -> empty
|
||||
return [];
|
||||
};
|
||||
|
||||
const claimFiles = getClaimFilesArray(claim);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Claim Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detailed view of the selected claim.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{claim && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
|
||||
{claim.patientName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
|
||||
<p className="text-gray-500">
|
||||
Claim ID: {claim.id?.toString().padStart(4, "0")}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Claim No: {claim.claimNumber || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Basic Information</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p>
|
||||
<span className="text-gray-500">Date of Birth:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.dateOfBirth)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Service Date:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.serviceDate)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Status:</span>{" "}
|
||||
<span
|
||||
className={`font-medium ${
|
||||
claim.status === "APPROVED"
|
||||
? "text-green-600"
|
||||
: claim.status === "CANCELLED"
|
||||
? "text-red-600"
|
||||
: claim.status === "REVIEW"
|
||||
? "text-yellow-600"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{claim?.status
|
||||
? claim.status.charAt(0).toUpperCase() +
|
||||
claim.status.slice(1).toLowerCase()
|
||||
: "Unknown"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Insurance Details</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p>
|
||||
<span className="text-gray-500">Claim Number:</span>{" "}
|
||||
{claim.claimNumber || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Insurance Provider:</span>{" "}
|
||||
{claim.insuranceProvider || "N/A"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Member ID:</span>{" "}
|
||||
{claim.memberId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Remarks:</span>{" "}
|
||||
{claim.remarks || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">Timestamps</h4>
|
||||
<p>
|
||||
<span className="text-gray-500">Created At:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.createdAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Updated At:</span>{" "}
|
||||
{formatDateToHumanReadable(claim.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{claim.staff && (
|
||||
<div className="space-y-2 pt-4">
|
||||
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
|
||||
<p>
|
||||
<span className="text-gray-500">Name:</span>{" "}
|
||||
{claim.staff.name}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Role:</span>{" "}
|
||||
{claim.staff.role}
|
||||
</p>
|
||||
{claim.staff.email && (
|
||||
<p>
|
||||
<span className="text-gray-500">Email:</span>{" "}
|
||||
{claim.staff.email}
|
||||
</p>
|
||||
)}
|
||||
{claim.staff.phone && (
|
||||
<p>
|
||||
<span className="text-gray-500">Phone:</span>{" "}
|
||||
{claim.staff.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
|
||||
<div className="mt-2 space-y-3">
|
||||
{claim.serviceLines.length > 0 ? (
|
||||
<>
|
||||
{claim.serviceLines.map((line, index) => (
|
||||
<div
|
||||
key={line.id}
|
||||
className="border p-3 rounded-md bg-gray-50"
|
||||
>
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Code:</span>{" "}
|
||||
{line.procedureCode}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-500">Procedure Date:</span>{" "}
|
||||
{formatDateToHumanReadable(line.procedureDate)}
|
||||
</p>
|
||||
{line.quad && (
|
||||
<p>
|
||||
<span className="text-gray-500">Quad:</span>{" "}
|
||||
{line.quad}
|
||||
</p>
|
||||
)}
|
||||
{line.arch && (
|
||||
<p>
|
||||
<span className="text-gray-500">Arch:</span>{" "}
|
||||
{line.arch}
|
||||
</p>
|
||||
)}
|
||||
{line.toothNumber && (
|
||||
<p>
|
||||
<span className="text-gray-500">Tooth Number:</span>{" "}
|
||||
{line.toothNumber}
|
||||
</p>
|
||||
)}
|
||||
{line.toothSurface && (
|
||||
<p>
|
||||
<span className="text-gray-500">
|
||||
Tooth Surface:
|
||||
</span>{" "}
|
||||
{line.toothSurface}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-gray-500">Billed Amount:</span>{" "}
|
||||
${Number(line.totalBilled).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
|
||||
Total Billed Amount: $
|
||||
{claim.serviceLines
|
||||
.reduce(
|
||||
(total, line) =>
|
||||
total + Number(line.totalBilled || 0),
|
||||
0
|
||||
)
|
||||
.toFixed(2)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500">No service lines available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing Teeth */}
|
||||
<div className="space-y-2 pt-4">
|
||||
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
|
||||
|
||||
<p>
|
||||
<span className="text-gray-500">Status:</span>{" "}
|
||||
{toStatusLabel((claim as any).missingTeethStatus)}
|
||||
</p>
|
||||
|
||||
{/* Only show details when the user chose "Specify Missing" */}
|
||||
{(claim as any).missingTeethStatus === "Yes_missing" &&
|
||||
(() => {
|
||||
const map = safeParseMissingTeeth(
|
||||
(claim as any).missingTeeth
|
||||
);
|
||||
const { permanent, primary } = splitTeeth(map);
|
||||
const hasAny = permanent.length > 0 || primary.length > 0;
|
||||
|
||||
if (!hasAny) {
|
||||
return (
|
||||
<p className="text-gray-500">
|
||||
No specific teeth marked as missing.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-3">
|
||||
{permanent.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">
|
||||
Permanent
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permanent.map((t) => (
|
||||
<ToothChip key={t.name} name={t.name} v={t.v} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{primary.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">
|
||||
Primary
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{primary.map((t) => (
|
||||
<ToothChip key={t.name} name={t.name} v={t.v} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(claim as any).missingTeethStatus === "endentulous" && (
|
||||
<p className="text-sm text-gray-700">Patient is edentulous.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim Files (metadata) */}
|
||||
<div className="pt-4">
|
||||
<h4 className="font-medium text-gray-900 flex items-center space-x-2">
|
||||
<Paperclip className="w-4 h-4 inline-block" />
|
||||
<span>Attached Files</span>
|
||||
</h4>
|
||||
|
||||
{claimFiles.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{claimFiles.map((f) => (
|
||||
<li
|
||||
key={f.id ?? f.filename}
|
||||
className="flex items-center justify-between border rounded-md p-3 bg-white"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<FileText className="w-5 h-5 text-gray-500 mt-1" />
|
||||
<div>
|
||||
<div className="font-medium">{f.filename}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{f.mimeType || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-gray-500">No files attached.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onEditClaim(claim);
|
||||
}}
|
||||
>
|
||||
Edit Claim
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-of-patient-table.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import ClaimsRecentTable from "./claims-recent-table";
|
||||
import { PatientTable } from "../patients/patient-table";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../ui/card";
|
||||
import { Patient } from "@repo/db/types";
|
||||
|
||||
interface ClaimsOfPatientModalProps {
|
||||
onNewClaim?: (patientId: number) => void;
|
||||
}
|
||||
|
||||
export default function ClaimsOfPatientModal({
|
||||
onNewClaim,
|
||||
}: ClaimsOfPatientModalProps) {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [claimsPage, setClaimsPage] = useState(1);
|
||||
|
||||
const handleSelectPatient = (patient: Patient | null) => {
|
||||
if (patient) {
|
||||
setSelectedPatient(patient);
|
||||
setClaimsPage(1);
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 py-8">
|
||||
{/* Claims Section */}
|
||||
{selectedPatient && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Claims for {selectedPatient.firstName} {selectedPatient.lastName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Displaying recent claims for the selected patient.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ClaimsRecentTable
|
||||
patientId={selectedPatient.id}
|
||||
allowView
|
||||
allowEdit
|
||||
allowDelete
|
||||
onPageChange={setClaimsPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Patients Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Patient Records</CardTitle>
|
||||
<CardDescription>
|
||||
Select any patient and View all their recent claims.
|
||||
</CardDescription>
|
||||
<CardDescription>
|
||||
Also create new claim for any patients.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PatientTable
|
||||
allowView
|
||||
allowCheckbox
|
||||
allowNewClaim
|
||||
onNewClaim={onNewClaim}
|
||||
onSelectPatient={handleSelectPatient}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
626
apps/Frontend/src/components/claims/claims-recent-table.tsx
Executable file
@@ -0,0 +1,626 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Delete,
|
||||
Edit,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAppDispatch } from "@/redux/hooks";
|
||||
import { setTaskStatus } from "@/redux/slices/seleniumClaimSubmitTaskSlice";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import LoadingScreen from "@/components/ui/LoadingScreen";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import ClaimViewModal from "./claim-view-modal";
|
||||
import ClaimEditModal from "./claim-edit-modal";
|
||||
import { Claim, ClaimStatus, ClaimWithServiceLines } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
|
||||
interface ClaimApiResponse {
|
||||
claims: ClaimWithServiceLines[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface ClaimsRecentTableProps {
|
||||
allowEdit?: boolean;
|
||||
allowView?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowCheckbox?: boolean;
|
||||
onSelectClaim?: (claim: Claim | null) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
patientId?: number;
|
||||
}
|
||||
|
||||
// 🔑 exported base key
|
||||
export const QK_CLAIMS_BASE = ["claims-recent"] as const;
|
||||
// helper for specific pages/patient scope
|
||||
export const qkClaimsRecent = (opts: {
|
||||
patientId?: number | null;
|
||||
page: number;
|
||||
}) =>
|
||||
opts.patientId
|
||||
? ([...QK_CLAIMS_BASE, "patient", opts.patientId, opts.page] as const)
|
||||
: ([...QK_CLAIMS_BASE, "global", opts.page] as const);
|
||||
|
||||
export default function ClaimsRecentTable({
|
||||
allowEdit,
|
||||
allowView,
|
||||
allowDelete,
|
||||
allowCheckbox,
|
||||
onSelectClaim,
|
||||
onPageChange,
|
||||
patientId,
|
||||
}: ClaimsRecentTableProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isViewClaimOpen, setIsViewClaimOpen] = useState(false);
|
||||
const [isEditClaimOpen, setIsEditClaimOpen] = useState(false);
|
||||
const [isDeleteClaimOpen, setIsDeleteClaimOpen] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const claimsPerPage = 5;
|
||||
const offset = (currentPage - 1) * claimsPerPage;
|
||||
|
||||
const [currentClaim, setCurrentClaim] = useState<
|
||||
ClaimWithServiceLines | undefined
|
||||
>(undefined);
|
||||
const [selectedClaimId, setSelectedClaimId] = useState<number | null>(null);
|
||||
|
||||
const handleSelectClaim = (claim: Claim) => {
|
||||
const isSelected = selectedClaimId === claim.id;
|
||||
const newSelectedId = isSelected ? null : claim.id;
|
||||
setSelectedClaimId(Number(newSelectedId));
|
||||
|
||||
if (onSelectClaim) {
|
||||
onSelectClaim(isSelected ? null : claim);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [patientId]);
|
||||
|
||||
const queryKey = qkClaimsRecent({
|
||||
patientId: patientId ?? undefined,
|
||||
page: currentPage,
|
||||
});
|
||||
|
||||
const {
|
||||
data: claimsData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<ClaimApiResponse, Error>({
|
||||
queryKey,
|
||||
|
||||
queryFn: async () => {
|
||||
const endpoint = patientId
|
||||
? `/api/claims/patient/${patientId}?limit=${claimsPerPage}&offset=${offset}`
|
||||
: `/api/claims/recent?limit=${claimsPerPage}&offset=${offset}`;
|
||||
|
||||
const res = await apiRequest("GET", endpoint);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || "Search failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
placeholderData: { claims: [], totalCount: 0 },
|
||||
});
|
||||
|
||||
const updateClaimMutation = useMutation({
|
||||
mutationFn: async (claim: ClaimWithServiceLines) => {
|
||||
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
|
||||
status: claim.status,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || "Failed to update claim");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsEditClaimOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Claim updated successfully!",
|
||||
variant: "default",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Update failed: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteClaimMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/claims/${id}`);
|
||||
return;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsDeleteClaimOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Claim deleted successfully!",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete claim: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditClaim = (claim: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsEditClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleViewClaim = (claim: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsViewClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClaim = (claim: ClaimWithServiceLines) => {
|
||||
setCurrentClaim(claim);
|
||||
setIsDeleteClaimOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeleteClaim = async () => {
|
||||
if (currentClaim) {
|
||||
if (typeof currentClaim.id === "number") {
|
||||
deleteClaimMutation.mutate(currentClaim.id);
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Selected claim is missing an ID for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No patient selected for deletion.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onPageChange) onPageChange(currentPage);
|
||||
}, [currentPage, onPageChange]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage),
|
||||
[claimsData?.totalCount, claimsPerPage]
|
||||
);
|
||||
|
||||
const startItem = offset + 1;
|
||||
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
|
||||
|
||||
const getInitialsFromName = (fullName: string) => {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
const filteredParts = parts.filter((part) => part.length > 0);
|
||||
if (filteredParts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const firstInitial = filteredParts[0]!.charAt(0).toUpperCase();
|
||||
if (filteredParts.length === 1) {
|
||||
return firstInitial;
|
||||
} else {
|
||||
const lastInitial =
|
||||
filteredParts[filteredParts.length - 1]!.charAt(0).toUpperCase();
|
||||
return firstInitial + lastInitial;
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarColor = (id: number) => {
|
||||
const colorClasses = [
|
||||
"bg-blue-500",
|
||||
"bg-teal-500",
|
||||
"bg-amber-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-green-500",
|
||||
"bg-purple-500",
|
||||
];
|
||||
return colorClasses[id % colorClasses.length];
|
||||
};
|
||||
|
||||
const getStatusInfo = (status?: ClaimStatus) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return {
|
||||
label: "Pending",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <Clock className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "APPROVED":
|
||||
return {
|
||||
label: "Approved",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckCircle className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
case "CANCELLED":
|
||||
return {
|
||||
label: "Cancelled",
|
||||
color: "bg-red-100 text-red-800",
|
||||
icon: <AlertCircle className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: status
|
||||
? status.charAt(0).toUpperCase() + status.slice(1)
|
||||
: "Unknown",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <AlertCircle className="h-3 w-3 mr-1" />,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalBilled = (claim: ClaimWithServiceLines) => {
|
||||
return claim.serviceLines.reduce(
|
||||
(sum, line) => sum + Number(line.totalBilled || 0),
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{allowCheckbox && <TableHead>Select</TableHead>}
|
||||
<TableHead>Claim ID</TableHead>
|
||||
<TableHead>Claim No</TableHead>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Submission Date</TableHead>
|
||||
<TableHead>Insurance Provider</TableHead>
|
||||
<TableHead>Member ID</TableHead>
|
||||
<TableHead>Total Billed</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<LoadingScreen />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : isError ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-8 text-red-500"
|
||||
>
|
||||
Error loading claims.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (claimsData?.claims ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No claims found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
claimsData?.claims.map((claim) => (
|
||||
<TableRow key={claim.id} className="hover:bg-gray-50">
|
||||
{allowCheckbox && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedClaimId === claim.id}
|
||||
onCheckedChange={() => handleSelectClaim(claim)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
CLM-{claim.id!.toString().padStart(4, "0")}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{claim.claimNumber ?? "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
className={`h-10 w-10 ${getAvatarColor(claim.patientId)}`}
|
||||
>
|
||||
<AvatarFallback className="text-white">
|
||||
{getInitialsFromName(claim.patientName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{claim.patientName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatDateToHumanReadable(claim.createdAt!)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
{claim.insuranceProvider ?? "Not specified"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
{claim.memberId ?? "Not specified"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900">
|
||||
${getTotalBilled(claim).toFixed(2)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const { label, color, icon } = getStatusInfo(
|
||||
claim.status
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{allowDelete && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleDeleteClaim(claim);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
aria-label="Delete Staff"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
)}
|
||||
{allowEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
handleEditClaim(claim);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{allowView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
handleViewClaim(claim);
|
||||
}}
|
||||
className="text-gray-600 hover:text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* {allowView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "pending",
|
||||
message: "Sending Data to Selenium...",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/claims/mh-provider-login",
|
||||
{
|
||||
memberId: claim.memberId,
|
||||
dateOfBirth: claim.dateOfBirth,
|
||||
submissionDate: claim.createdAt,
|
||||
firstName: claim.patientName?.split(' ')[0] || '',
|
||||
lastName: claim.patientName?.split(' ').slice(1).join(' ') || '',
|
||||
procedureCode: claim.serviceLines?.[0]?.procedureCode || '',
|
||||
toothNumber: claim.serviceLines?.[0]?.toothNumber || '',
|
||||
toothSurface: claim.serviceLines?.[0]?.toothSurface || '',
|
||||
insuranceSiteKey: "MH",
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
if (data?.status === "success") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "success",
|
||||
message: "Claims automation completed. Browser remains open.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
handleViewClaim(claim);
|
||||
}
|
||||
} catch {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
status: "error",
|
||||
message: "Selenium submission failed",
|
||||
})
|
||||
);
|
||||
handleViewClaim(claim);
|
||||
}
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
Claims
|
||||
</Button>
|
||||
)} */}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteClaimOpen}
|
||||
onConfirm={handleConfirmDeleteClaim}
|
||||
onCancel={() => setIsDeleteClaimOpen(false)}
|
||||
entityName={currentClaim?.patientName}
|
||||
/>
|
||||
|
||||
{isViewClaimOpen && currentClaim && (
|
||||
<ClaimViewModal
|
||||
isOpen={isViewClaimOpen}
|
||||
onClose={() => setIsViewClaimOpen(false)}
|
||||
onOpenChange={(open) => setIsViewClaimOpen(open)}
|
||||
onEditClaim={(claim) => handleEditClaim(claim)}
|
||||
claim={currentClaim}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditClaimOpen && currentClaim && (
|
||||
<ClaimEditModal
|
||||
isOpen={isEditClaimOpen}
|
||||
onClose={() => setIsEditClaimOpen(false)}
|
||||
onOpenChange={(open) => setIsEditClaimOpen(open)}
|
||||
claim={currentClaim}
|
||||
onSave={(updatedClaim) => {
|
||||
updateClaimMutation.mutate(updatedClaim);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of {claimsData?.totalCount || 0}{" "}
|
||||
results
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers(currentPage, totalPages).map((page, idx) => (
|
||||
<PaginationItem key={idx}>
|
||||
{page === "..." ? (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
83
apps/Frontend/src/components/claims/claims-ui.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function RemarksField({
|
||||
value,
|
||||
onChange,
|
||||
debounceMs = 250, // tweak (150–300) if you like
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
const [local, setLocal] = React.useState(() => value);
|
||||
|
||||
// Track last prop we saw to detect true external changes
|
||||
const lastPropRef = React.useRef(value);
|
||||
React.useEffect(() => {
|
||||
if (value !== lastPropRef.current && value !== local) {
|
||||
// Only sync when parent changed from elsewhere
|
||||
setLocal(value);
|
||||
}
|
||||
lastPropRef.current = value;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]); // (intentionally ignoring `local` in deps)
|
||||
|
||||
// Debounce: call parent onChange after user pauses typing
|
||||
const timerRef = React.useRef<number | null>(null);
|
||||
const schedulePush = React.useCallback(
|
||||
(next: string) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
onChange(next);
|
||||
// update lastPropRef so the next parent echo won't resync over local
|
||||
lastPropRef.current = next;
|
||||
}, debounceMs);
|
||||
},
|
||||
[onChange, debounceMs]
|
||||
);
|
||||
|
||||
// Flush on unmount to avoid losing the last input
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
onChange(local);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="remarks"
|
||||
placeholder="Paste clinical notes here"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={local}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setLocal(next); // instant local update (no lag)
|
||||
schedulePush(next); // debounced parent update
|
||||
}}
|
||||
onBlur={() => {
|
||||
// ensure latest text is pushed when the field loses focus
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (local !== lastPropRef.current) {
|
||||
onChange(local);
|
||||
lastPropRef.current = local;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
191
apps/Frontend/src/components/claims/tooth-ui.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { Label } from "recharts";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
// ——— Missing Teeth helpers for claim-view and edit modal———
|
||||
type MissingMap = Record<string, ToothVal | undefined>;
|
||||
|
||||
export function toStatusLabel(s?: string) {
|
||||
if (!s) return "Unknown";
|
||||
if (s === "No_missing") return "No Missing";
|
||||
if (s === "endentulous") return "Edentulous";
|
||||
if (s === "Yes_missing") return "Specify Missing";
|
||||
// best-effort prettify
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function safeParseMissingTeeth(raw: unknown): MissingMap {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as MissingMap;
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
if (typeof raw === "object") return raw as MissingMap;
|
||||
return {};
|
||||
}
|
||||
|
||||
const PERM = new Set(Array.from({ length: 32 }, (_, i) => `T_${i + 1}`));
|
||||
const PRIM = new Set(Array.from("ABCDEFGHIJKLMNOPQRST").map((ch) => `T_${ch}`));
|
||||
|
||||
export function splitTeeth(map: MissingMap) {
|
||||
const permanent: Array<{ name: string; v: ToothVal }> = [];
|
||||
const primary: Array<{ name: string; v: ToothVal }> = [];
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (!v) continue;
|
||||
if (PERM.has(k)) permanent.push({ name: k, v });
|
||||
else if (PRIM.has(k)) primary.push({ name: k, v });
|
||||
}
|
||||
// stable, human-ish order
|
||||
permanent.sort((a, b) => Number(a.name.slice(2)) - Number(b.name.slice(2)));
|
||||
primary.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { permanent, primary };
|
||||
}
|
||||
|
||||
export function ToothChip({ name, v }: { name: string; v: ToothVal }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs bg-white">
|
||||
<span className="font-medium">{name.replace("T_", "")}</span>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded border">
|
||||
{v}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToothVal = "X" | "O";
|
||||
export type MissingMapStrict = Record<string, ToothVal>;
|
||||
|
||||
/* ---------- parsing helpers ---------- */
|
||||
const PERM_NUMBERS = new Set(
|
||||
Array.from({ length: 32 }, (_, i) => String(i + 1))
|
||||
);
|
||||
const PRIM_LETTERS = new Set(Array.from("ABCDEFGHIJKLMNOPQRST"));
|
||||
|
||||
function normalizeToothToken(token: string): string | null {
|
||||
const t = token.trim().toUpperCase();
|
||||
if (!t) return null;
|
||||
if (PERM_NUMBERS.has(t)) return t; // 1..32
|
||||
if (t.length === 1 && PRIM_LETTERS.has(t)) return t; // A..T
|
||||
return null;
|
||||
}
|
||||
|
||||
function listToEntries(list: string, val: ToothVal): Array<[string, ToothVal]> {
|
||||
if (!list) return [];
|
||||
const seen = new Set<string>();
|
||||
return list
|
||||
.split(/[,\s]+/g) // commas OR spaces
|
||||
.map(normalizeToothToken) // uppercase + validate
|
||||
.filter((t): t is string => !!t)
|
||||
.filter((t) => {
|
||||
// de-duplicate within field
|
||||
if (seen.has(t)) return false;
|
||||
seen.add(t);
|
||||
return true;
|
||||
})
|
||||
.map((t) => [`T_${t}`, val]);
|
||||
}
|
||||
|
||||
/** Build map; 'O' overrides 'X' when duplicated across fields. */
|
||||
export function mapFromLists(
|
||||
missingList: string,
|
||||
pullList: string
|
||||
): MissingMapStrict {
|
||||
const map: MissingMapStrict = {};
|
||||
for (const [k, v] of listToEntries(missingList, "X")) map[k] = v;
|
||||
for (const [k, v] of listToEntries(pullList, "O")) map[k] = v;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** For initializing the inputs from an existing map (used only on mount or clear). */
|
||||
export function listsFromMap(map: Record<string, ToothVal | undefined>): {
|
||||
missing: string;
|
||||
toPull: string;
|
||||
} {
|
||||
const missing: string[] = [];
|
||||
const toPull: string[] = [];
|
||||
for (const [k, v] of Object.entries(map || {})) {
|
||||
if (v === "X") missing.push(k.replace(/^T_/, ""));
|
||||
else if (v === "O") toPull.push(k.replace(/^T_/, ""));
|
||||
}
|
||||
const sort = (a: string, b: string) => {
|
||||
const na = Number(a),
|
||||
nb = Number(b);
|
||||
const an = !Number.isNaN(na),
|
||||
bn = !Number.isNaN(nb);
|
||||
if (an && bn) return na - nb;
|
||||
if (an) return -1;
|
||||
if (bn) return 1;
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
missing.sort(sort);
|
||||
toPull.sort(sort);
|
||||
return { missing: missing.join(", "), toPull: toPull.join(", ") };
|
||||
}
|
||||
|
||||
/* ---------- UI ---------- */
|
||||
export function MissingTeethSimple({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
/** Must match ClaimFormData.missingTeeth exactly */
|
||||
value: MissingMapStrict;
|
||||
onChange: (next: MissingMapStrict) => void;
|
||||
}) {
|
||||
// initialize text inputs from incoming map
|
||||
const init = React.useMemo(() => listsFromMap(value), []); // only on mount
|
||||
const [missingField, setMissingField] = React.useState(init.missing);
|
||||
const [pullField, setPullField] = React.useState(init.toPull);
|
||||
|
||||
// only resync when parent CLEARS everything (so your Clear All works)
|
||||
React.useEffect(() => {
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
setMissingField("");
|
||||
setPullField("");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const recompute = (mStr: string, pStr: string) => {
|
||||
onChange(mapFromLists(mStr, pStr));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
{/* simple text label (no recharts Label) */}
|
||||
<div className="text-sm font-medium">Tooth Number - Missing - X</div>
|
||||
<Input
|
||||
placeholder="e.g. 1,2,A,B"
|
||||
value={missingField}
|
||||
onChange={(e) => {
|
||||
const m = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||
setMissingField(m);
|
||||
recompute(m, pullField);
|
||||
}}
|
||||
aria-label="Tooth Numbers — Missing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
Tooth Number - To be pulled - O
|
||||
</div>
|
||||
<Input
|
||||
placeholder="e.g. 4,5,D"
|
||||
value={pullField}
|
||||
onChange={(e) => {
|
||||
const p = e.target.value.toUpperCase(); // keep uppercase in the field
|
||||
setPullField(p);
|
||||
recompute(missingField, p);
|
||||
}}
|
||||
aria-label="Tooth Numbers — To be pulled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable file
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable file
@@ -0,0 +1,203 @@
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Improved Breadcrumbs helper component
|
||||
* - Renders a pill-style path with chevrons
|
||||
* - Collapses middle items when path is long and exposes them via an ellipsis dropdown
|
||||
* - Clickable items, accessible, responsive truncation
|
||||
*/
|
||||
|
||||
export type FolderMeta = {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
parentId: number | null;
|
||||
};
|
||||
|
||||
export function Breadcrumbs({
|
||||
path,
|
||||
onNavigate,
|
||||
}: {
|
||||
path: FolderMeta[];
|
||||
onNavigate: (id: number | null) => void;
|
||||
}) {
|
||||
const [openEllipsis, setOpenEllipsis] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!dropdownRef.current) return;
|
||||
if (!dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpenEllipsis(false);
|
||||
}
|
||||
}
|
||||
if (openEllipsis) {
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", onDocClick);
|
||||
}, [openEllipsis]);
|
||||
|
||||
// Render strategy: if path.length <= 4 show all; else show: first, ellipsis, last 2
|
||||
const showAll = path.length <= 4;
|
||||
const first = path[0];
|
||||
const lastTwo = path.slice(Math.max(0, path.length - 2));
|
||||
const middle = path.slice(1, Math.max(1, path.length - 2));
|
||||
|
||||
// utility classes
|
||||
const inactiveChip =
|
||||
"inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-muted hover:bg-muted/80 text-muted-foreground";
|
||||
const activeChip =
|
||||
"inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-primary/10 text-primary ring-1 ring-primary/20";
|
||||
|
||||
// render a chip with optional active flag
|
||||
function Chip({
|
||||
id,
|
||||
name,
|
||||
active,
|
||||
}: {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={active ? activeChip : inactiveChip}
|
||||
onClick={() => onNavigate(id)}
|
||||
title={name ?? (id ? `Folder ${id}` : "My Cloud Storage")}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M3 7h18v10H3z" />
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{name ?? (id ? `Folder ${id}` : "My Cloud Storage")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// small slash separator (visible between chips)
|
||||
const Slash = () => <li className="text-muted-foreground px-1">/</li>;
|
||||
|
||||
return (
|
||||
// Card-like background for the entire breadcrumb strip
|
||||
<nav className="bg-card p-3 rounded-md shadow-sm" aria-label="breadcrumb">
|
||||
<ol className="flex items-center gap-2 flex-wrap">
|
||||
{/* Root chip */}
|
||||
<li>
|
||||
<button
|
||||
className={path.length === 0 ? activeChip : inactiveChip}
|
||||
onClick={() => onNavigate(null)}
|
||||
title="My Cloud Storage"
|
||||
aria-current={path.length === 0 ? "page" : undefined}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M3 11.5L12 4l9 7.5V20a1 1 0 0 1-1 1h-4v-6H8v6H4a1 1 0 0 1-1-1v-8.5z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">My Cloud Storage</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{path.length > 0 && <Slash />}
|
||||
|
||||
{showAll ? (
|
||||
// show all crumbs as chips with slashes between
|
||||
path.map((p, idx) => (
|
||||
<Fragment key={String(p.id ?? idx)}>
|
||||
<li>
|
||||
<Chip
|
||||
id={p.id}
|
||||
name={p.name}
|
||||
active={idx === path.length - 1}
|
||||
/>
|
||||
</li>
|
||||
{idx !== path.length - 1 && <Slash />}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
// collapsed view: first, ellipsis dropdown, last two (with slashes)
|
||||
<>
|
||||
{first && (
|
||||
<>
|
||||
<li>
|
||||
<Chip id={first.id} name={first.name} active={false} />
|
||||
</li>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
|
||||
<li>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpenEllipsis((s) => !s)}
|
||||
aria-expanded={openEllipsis}
|
||||
className={inactiveChip}
|
||||
title="Show hidden path"
|
||||
>
|
||||
•••
|
||||
</button>
|
||||
|
||||
{/* dropdown for middle items */}
|
||||
{openEllipsis && (
|
||||
<div className="absolute left-0 mt-2 w-56 bg-popover border rounded shadow z-50">
|
||||
<ul className="p-2">
|
||||
{middle.map((m) => (
|
||||
<li key={String(m.id)}>
|
||||
<button
|
||||
className="w-full text-left px-2 py-1 rounded hover:bg-accent/5 text-sm text-muted-foreground"
|
||||
onClick={() => {
|
||||
setOpenEllipsis(false);
|
||||
onNavigate(m.id);
|
||||
}}
|
||||
>
|
||||
{m.name ?? `Folder ${m.id}`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{middle.length === 0 && (
|
||||
<li>
|
||||
<div className="px-2 py-1 text-sm text-muted-foreground">
|
||||
No hidden folders
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<Slash />
|
||||
|
||||
{lastTwo.map((p, idx) => (
|
||||
<Fragment key={String(p.id ?? `tail-${idx}`)}>
|
||||
<li>
|
||||
<Chip
|
||||
id={p.id}
|
||||
name={p.name}
|
||||
active={idx === lastTwo.length - 1}
|
||||
/>
|
||||
</li>
|
||||
{idx !== lastTwo.length - 1 && <Slash />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable file
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable file
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Download, Maximize2, Minimize2, Trash2, X } from "lucide-react";
|
||||
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { cloudFilesQueryKeyRoot } from "./files-section";
|
||||
|
||||
type Props = {
|
||||
fileId: number | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export default function FilePreviewModal({
|
||||
fileId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [meta, setMeta] = useState<any | null>(null);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !fileId) return;
|
||||
|
||||
let cancelled = false;
|
||||
let createdUrl: string | null = null;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMeta(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
try {
|
||||
const metaRes = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/files/${fileId}`
|
||||
);
|
||||
const metaJson = await metaRes.json();
|
||||
if (!metaRes.ok) {
|
||||
throw new Error(metaJson?.message || "Failed to load file metadata");
|
||||
}
|
||||
if (cancelled) return;
|
||||
setMeta(metaJson.data);
|
||||
|
||||
const contentRes = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/files/${fileId}/content`
|
||||
);
|
||||
if (!contentRes.ok) {
|
||||
let msg = `Preview request failed (${contentRes.status})`;
|
||||
try {
|
||||
const j = await contentRes.json();
|
||||
msg = j?.message ?? msg;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await contentRes.blob();
|
||||
if (cancelled) return;
|
||||
createdUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(createdUrl);
|
||||
} catch (err: any) {
|
||||
if (!cancelled) setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (createdUrl) {
|
||||
URL.revokeObjectURL(createdUrl);
|
||||
}
|
||||
};
|
||||
}, [isOpen, fileId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const mime = meta?.mimeType ?? "";
|
||||
|
||||
async function handleDownload() {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/files/${fileId}/download`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = meta?.name ?? `file-${fileId}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!fileId) return;
|
||||
|
||||
setIsDeleteOpen(false);
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/cloud-storage/files/${fileId}`
|
||||
);
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message || `Delete failed (${res.status})`);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Deleted",
|
||||
description: `File "${meta?.name ?? `file-${fileId}`}" deleted.`,
|
||||
});
|
||||
|
||||
// notify parent to refresh lists if they provided callback
|
||||
if (typeof onDeleted === "function") {
|
||||
try {
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
// ignore parent errors
|
||||
}
|
||||
}
|
||||
|
||||
// close modal
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// container sizing classes
|
||||
const containerBase =
|
||||
"bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
|
||||
const sizeClass = isFullscreen
|
||||
? "w-[95vw] h-[95vh]"
|
||||
: "w-[min(1200px,95vw)] h-[85vh]";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
|
||||
{/* header */}
|
||||
|
||||
<div className="flex items-start justify-between gap-3 pb-2 border-b">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold truncate">
|
||||
{meta?.name ?? "Preview"}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 truncate">
|
||||
{meta?.mimeType ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsFullscreen((s) => !s)}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
className="p-2 rounded hover:bg-gray-100"
|
||||
aria-label="Toggle fullscreen"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<Button variant="ghost" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
{" "}
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-auto mt-3">
|
||||
{/* loading / error */}
|
||||
{loading && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
Loading preview…
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
|
||||
{/* image */}
|
||||
{!loading && !error && blobUrl && mime.startsWith("image/") && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={meta?.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* pdf */}
|
||||
{!loading &&
|
||||
!error &&
|
||||
blobUrl &&
|
||||
(mime === "application/pdf" || mime.endsWith("/pdf")) && (
|
||||
<div className="w-full h-full">
|
||||
<iframe
|
||||
src={blobUrl}
|
||||
title={meta?.name}
|
||||
className="w-full h-full border-0"
|
||||
style={{ minHeight: 400 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* fallback */}
|
||||
{!loading &&
|
||||
!error &&
|
||||
blobUrl &&
|
||||
!mime.startsWith("image/") &&
|
||||
!mime.includes("pdf") && (
|
||||
<div className="p-4">
|
||||
<p>Preview not available for this file type.</p>
|
||||
<p className="mt-2">
|
||||
<a
|
||||
href={blobUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Open raw
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
entityName={meta?.name ?? undefined}
|
||||
onCancel={() => setIsDeleteOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
@@ -0,0 +1,584 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Plus,
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
Download,
|
||||
Edit3 as EditIcon,
|
||||
} from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { CloudFile } from "@repo/db/types";
|
||||
import { MultipleFileUploadZone } from "@/components/file-upload/multiple-file-upload-zone";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { Menu, Item, contextMenu } from "react-contexify";
|
||||
import "react-contexify/dist/ReactContexify.css";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
} from "@/components/ui/pagination";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
import { NewFolderModal } from "./new-folder-modal";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import FilePreviewModal from "./file-preview-modal";
|
||||
import { cloudSearchQueryKeyRoot } from "./search-bar";
|
||||
|
||||
export type FilesSectionProps = {
|
||||
parentId: number | null;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
onFileOpen?: (fileId: number) => void;
|
||||
};
|
||||
|
||||
// canonical root key for files list queries (per-parent)
|
||||
export const cloudFilesQueryKeyRoot = ["cloud-files"];
|
||||
/**
|
||||
* Build a full query key for files list under a parent folder with page parameters.
|
||||
* Example usage:
|
||||
* cloudFilesQueryKeyBase(parentId, page, pageSize)
|
||||
*/
|
||||
export const cloudFilesQueryKeyBase = (
|
||||
parentId: number | null,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) => [
|
||||
"cloud-files",
|
||||
parentId === null ? "null" : String(parentId),
|
||||
page,
|
||||
pageSize,
|
||||
];
|
||||
|
||||
const FILES_LIMIT_DEFAULT = 20;
|
||||
const MAX_FILE_MB = 10;
|
||||
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
||||
|
||||
function fileIcon(mime?: string) {
|
||||
if (!mime) return <FileIcon className="h-6 w-6" />;
|
||||
if (mime.startsWith("image/")) return <ImageIcon className="h-6 w-6" />;
|
||||
if (mime === "application/pdf" || mime.endsWith("/pdf"))
|
||||
return <FileText className="h-6 w-6" />;
|
||||
return <FileIcon className="h-6 w-6" />;
|
||||
}
|
||||
|
||||
export default function FilesSection({
|
||||
parentId,
|
||||
pageSize = FILES_LIMIT_DEFAULT,
|
||||
className,
|
||||
onFileOpen,
|
||||
}: FilesSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
|
||||
const [data, setData] = useState<CloudFile[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// upload modal and ref
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const uploadRef = useRef<any>(null);
|
||||
|
||||
// rename/delete
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||
const [renameInitial, setRenameInitial] = useState("");
|
||||
|
||||
// delete dialog
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
||||
|
||||
// preview modal
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [previewFileId, setPreviewFileId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPage(currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentId, currentPage]);
|
||||
|
||||
async function loadPage(page: number) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const fid = parentId === null ? "null" : String(parentId);
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/items/files?parentId=${encodeURIComponent(
|
||||
fid
|
||||
)}&limit=${pageSize}&offset=${offset}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Failed to load files");
|
||||
const rows: CloudFile[] = Array.isArray(json.data) ? json.data : [];
|
||||
setData(rows);
|
||||
const t =
|
||||
typeof json.totalCount === "number"
|
||||
? json.totalCount
|
||||
: typeof json.total === "number"
|
||||
? json.total
|
||||
: rows.length;
|
||||
setTotal(t);
|
||||
} catch (err: any) {
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
toast({
|
||||
title: "Failed to load files",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(e: React.MouseEvent, file: CloudFile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: "files-section-menu",
|
||||
event: e.nativeEvent,
|
||||
props: { file },
|
||||
});
|
||||
}
|
||||
|
||||
// rename
|
||||
function openRename(file: CloudFile) {
|
||||
setRenameTargetId(Number(file.id));
|
||||
setRenameInitial(file.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function submitRename(newName: string) {
|
||||
if (!renameTargetId) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/cloud-storage/files/${renameTargetId}`,
|
||||
{ name: newName }
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Rename failed");
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
toast({ title: "File renamed" });
|
||||
|
||||
loadPage(currentPage);
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Rename failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
function openDelete(file: CloudFile) {
|
||||
setDeleteTarget(file);
|
||||
setIsDeleteOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/cloud-storage/files/${deleteTarget.id}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Delete failed");
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({ title: "File deleted" });
|
||||
|
||||
// reload current page (ensure page index valid)
|
||||
loadPage(currentPage);
|
||||
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||
});
|
||||
// invalidate any cloud-files lists (so file lists refresh)
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
// invalidate any cloud-search queries so search results refresh
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// download (context menu) - (fetch bytes from backend host via wrapper)
|
||||
async function handleDownload(file: CloudFile) {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/files/${file.id}/download`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name ?? `file-${file.id}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
// revoke after a bit
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
contextMenu.hideAll();
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// upload: get files from MultipleFileUploadZone (imperative handle)
|
||||
async function handleUploadSubmit() {
|
||||
const files: File[] = uploadRef.current?.getFiles?.() ?? [];
|
||||
if (!files.length) {
|
||||
toast({
|
||||
title: "No files selected",
|
||||
description: "Please choose files to upload before clicking Upload.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// pre-check all files and show errors / skip too-large files
|
||||
const oversized = files.filter((f) => f.size > MAX_FILE_BYTES);
|
||||
if (oversized.length) {
|
||||
oversized.slice(0, 5).forEach((f) =>
|
||||
toast({
|
||||
title: "File too large",
|
||||
description: `${f.name} is ${Math.round(f.size / 1024 / 1024)} MB — max ${MAX_FILE_MB} MB allowed.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
);
|
||||
// Remove oversized files from the upload list (upload the rest)
|
||||
}
|
||||
|
||||
const toUpload = files.filter((f) => f.size <= MAX_FILE_BYTES);
|
||||
if (toUpload.length === 0) {
|
||||
// nothing to upload
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const f of toUpload) {
|
||||
const fid = parentId === null ? "null" : String(parentId);
|
||||
const initRes = await apiRequest(
|
||||
"POST",
|
||||
`/api/cloud-storage/folders/${encodeURIComponent(fid)}/files`,
|
||||
{
|
||||
userId,
|
||||
name: f.name,
|
||||
mimeType: f.type || null,
|
||||
expectedSize: f.size,
|
||||
totalChunks: 1,
|
||||
}
|
||||
);
|
||||
const initJson = await initRes.json();
|
||||
const created = initJson?.data;
|
||||
if (!created || typeof created.id !== "number")
|
||||
throw new Error("Init failed");
|
||||
const raw = await f.arrayBuffer();
|
||||
// upload chunk
|
||||
await apiRequest(
|
||||
"POST",
|
||||
`/api/cloud-storage/files/${created.id}/chunks?seq=0`,
|
||||
raw
|
||||
);
|
||||
// finalize
|
||||
await apiRequest(
|
||||
"POST",
|
||||
`/api/cloud-storage/files/${created.id}/complete`,
|
||||
{}
|
||||
);
|
||||
toast({ title: "Upload complete", description: f.name });
|
||||
}
|
||||
setIsUploadOpen(false);
|
||||
loadPage(currentPage);
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(total, currentPage * pageSize);
|
||||
|
||||
// open preview (single click)
|
||||
function openPreview(file: CloudFile) {
|
||||
setPreviewFileId(Number(file.id));
|
||||
setIsPreviewOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Files</CardTitle>
|
||||
<CardDescription>Manage Files in this folder</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="inline-flex items-center px-4 py-2"
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Upload
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{data.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
|
||||
onContextMenu={(e) => showMenu(e, file)}
|
||||
onClick={() => openPreview(file)}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
||||
{fileIcon((file as any).mimeType)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm truncate text-center"
|
||||
style={{ maxWidth: 140 }}
|
||||
>
|
||||
<div title={file.name}>{file.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{((file as any).fileSize ?? 0).toString()} bytes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of {total} results
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage((p) => Math.max(1, p - 1));
|
||||
}}
|
||||
className={
|
||||
currentPage === 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers(currentPage, totalPages).map(
|
||||
(page, idx) => (
|
||||
<PaginationItem key={idx}>
|
||||
{page === "..." ? (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage((p) => Math.min(totalPages, p + 1));
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* context menu */}
|
||||
<Menu id="files-section-menu" animation="fade">
|
||||
<Item onClick={({ props }: any) => openRename(props.file)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<EditIcon className="h-4 w-4" /> Rename
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => handleDownload(props.file)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => openDelete(props.file)}>
|
||||
<span className="flex items-center gap-2 text-red-600">
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</span>
|
||||
</Item>
|
||||
</Menu>
|
||||
|
||||
{/* upload modal using MultipleFileUploadZone (imperative handle) */}
|
||||
{isUploadOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
|
||||
<MultipleFileUploadZone
|
||||
ref={uploadRef}
|
||||
acceptedFileTypes="application/pdf,image/*"
|
||||
maxFiles={10}
|
||||
maxFileSizeMB={10}
|
||||
maxFileSizeByType={{ "application/pdf": 10, "image/*": 5 }}
|
||||
isUploading={uploading}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUploadSubmit} disabled={uploading}>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* rename modal (reusing NewFolderModal for simplicity) */}
|
||||
<NewFolderModal
|
||||
isOpen={isRenameOpen}
|
||||
initialName={renameInitial}
|
||||
title="Rename File"
|
||||
submitLabel="Rename"
|
||||
onClose={() => {
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
}}
|
||||
onSubmit={submitRename}
|
||||
/>
|
||||
|
||||
{/* FIle Preview Modal */}
|
||||
<FilePreviewModal
|
||||
fileId={previewFileId}
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
// close preview
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
|
||||
// reload this folder page
|
||||
loadPage(currentPage);
|
||||
|
||||
// invalidate caches
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudFilesQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudSearchQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* delete confirm */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
entityName={deleteTarget?.name}
|
||||
onCancel={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable file
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable file
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import FolderSection from "@/components/cloud-storage/folder-section";
|
||||
import FilesSection from "@/components/cloud-storage/files-section";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Breadcrumbs, FolderMeta } from "./bread-crumb";
|
||||
|
||||
type Props = {
|
||||
folderId: number | null;
|
||||
onClose?: () => void;
|
||||
onViewChange?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
export default function FolderPanel({
|
||||
folderId,
|
||||
onClose,
|
||||
onViewChange,
|
||||
}: Props) {
|
||||
const [currentFolderId, setCurrentFolderId] = useState<number | null>(
|
||||
folderId ?? null
|
||||
);
|
||||
const [path, setPath] = useState<FolderMeta[]>([]);
|
||||
const [isLoadingPath, setIsLoadingPath] = useState(false);
|
||||
|
||||
// When the panel opens to a different initial folder, sync and notify parent
|
||||
useEffect(() => {
|
||||
setCurrentFolderId(folderId ?? null);
|
||||
onViewChange?.(folderId ?? null);
|
||||
}, [folderId, onViewChange]);
|
||||
|
||||
// notify parent when viewed folder changes
|
||||
useEffect(() => {
|
||||
onViewChange?.(currentFolderId);
|
||||
}, [currentFolderId, onViewChange]);
|
||||
|
||||
// whenever currentFolderId changes we load the ancestor path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function buildPath(fid: number | null) {
|
||||
setIsLoadingPath(true);
|
||||
try {
|
||||
// We'll build path from root -> ... -> current. Since we don't know
|
||||
// if backend provides a single endpoint for ancestry, we'll fetch
|
||||
// current folder and walk parents until null. If fid is null then path is empty.
|
||||
if (fid == null) {
|
||||
if (mounted) setPath([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const collected: FolderMeta[] = [];
|
||||
let cursor: number | null = fid;
|
||||
|
||||
// keep a safety cap to avoid infinite loop in case of cycles
|
||||
const MAX_DEPTH = 50;
|
||||
let depth = 0;
|
||||
|
||||
while (cursor != null && depth < MAX_DEPTH) {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/folders/${cursor}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok)
|
||||
throw new Error(json?.message || "Failed to fetch folder");
|
||||
|
||||
const folder = json?.data ?? json ?? null;
|
||||
// normalize
|
||||
const meta: FolderMeta = {
|
||||
id: folder?.id ?? null,
|
||||
name: folder?.name ?? null,
|
||||
parentId: folder?.parentId ?? null,
|
||||
};
|
||||
|
||||
// prepend (we are walking up) then continue with parent
|
||||
collected.push(meta);
|
||||
cursor = meta.parentId;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
// collected currently top-down from current -> root. We need root->...->current
|
||||
const rootToCurrent = collected.slice().reverse();
|
||||
// we don't include the root (null) as an explicit item; Breadcrumbs shows "My Cloud Storage"
|
||||
if (mounted) setPath(rootToCurrent);
|
||||
} catch (err) {
|
||||
console.error("buildPath error", err);
|
||||
if (mounted) setPath([]);
|
||||
} finally {
|
||||
if (mounted) setIsLoadingPath(false);
|
||||
}
|
||||
}
|
||||
|
||||
buildPath(currentFolderId);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [currentFolderId]);
|
||||
|
||||
// handler when child folder is clicked inside FolderSection
|
||||
function handleChildSelect(childFolderId: number | null) {
|
||||
// if user re-clicks current folder id as toggle, we still want to navigate into it.
|
||||
setCurrentFolderId(childFolderId);
|
||||
onViewChange?.(childFolderId); // keep page in sync
|
||||
}
|
||||
|
||||
// navigate via breadcrumb (id may be null for root)
|
||||
function handleNavigateTo(id: number | null) {
|
||||
setCurrentFolderId(id);
|
||||
onViewChange?.(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{currentFolderId == null
|
||||
? "My Cloud Storage"
|
||||
: `Folder : ${path[path.length - 1]?.name ?? currentFolderId}`}
|
||||
</h2>
|
||||
<div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-md text-sm hover:bg-gray-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb / path strip */}
|
||||
<div>
|
||||
{/* show breadcrumbs even if loading; breadcrumbs show 'My Cloud Storage' + path */}
|
||||
<Breadcrumbs path={path} onNavigate={handleNavigateTo} />
|
||||
</div>
|
||||
|
||||
{/* stacked vertically: folders on top, files below */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="w-full">
|
||||
{/* pass onSelect so FolderSection can tell the panel to navigate into a child */}
|
||||
<FolderSection
|
||||
parentId={currentFolderId}
|
||||
onSelect={handleChildSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<FilesSection parentId={currentFolderId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable file
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable file
@@ -0,0 +1,400 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Folder as FolderIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit3 as EditIcon,
|
||||
} from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { CloudFolder } from "@repo/db/types";
|
||||
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { Menu, Item, contextMenu } from "react-contexify";
|
||||
import "react-contexify/dist/ReactContexify.css";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
} from "@/components/ui/pagination";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
import { recentTopLevelFoldersQueryKey } from "./recent-top-level-folder-modal";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
export type FolderSectionProps = {
|
||||
parentId: number | null;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
onSelect?: (folderId: number | null) => void;
|
||||
};
|
||||
|
||||
export default function FolderSection({
|
||||
parentId,
|
||||
pageSize = 10,
|
||||
className,
|
||||
onSelect,
|
||||
}: FolderSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
|
||||
const [data, setData] = useState<CloudFolder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [isNewOpen, setIsNewOpen] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [renameInitial, setRenameInitial] = useState("");
|
||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// reset selectedId and page when parent changes
|
||||
useEffect(() => {
|
||||
setSelectedId(null);
|
||||
setCurrentPage(1);
|
||||
}, [parentId]);
|
||||
|
||||
// load page
|
||||
useEffect(() => {
|
||||
loadPage(currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentId, currentPage]);
|
||||
|
||||
async function loadPage(page: number) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const pid = parentId === null ? "null" : String(parentId);
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/items/folders?parentId=${encodeURIComponent(
|
||||
pid
|
||||
)}&limit=${pageSize}&offset=${offset}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Failed to load folders");
|
||||
const rows: CloudFolder[] = Array.isArray(json.data) ? json.data : [];
|
||||
setData(rows);
|
||||
const t =
|
||||
typeof json.total === "number"
|
||||
? json.total
|
||||
: typeof json.totalCount === "number"
|
||||
? json.totalCount
|
||||
: rows.length;
|
||||
setTotal(t);
|
||||
} catch (err: any) {
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
toast({
|
||||
title: "Failed to load folders",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// tile click toggles selection
|
||||
function handleTileClick(id: number) {
|
||||
const next = selectedId === id ? null : id;
|
||||
setSelectedId(next);
|
||||
onSelect?.(next);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
// right-click menu via react-contexify
|
||||
function showMenu(e: React.MouseEvent, folder: CloudFolder) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: `folder-section-menu`,
|
||||
event: e.nativeEvent,
|
||||
props: { folder },
|
||||
});
|
||||
}
|
||||
|
||||
// create folder
|
||||
async function handleCreate(name: string) {
|
||||
if (!userId) {
|
||||
toast({
|
||||
title: "Not signed in",
|
||||
description: "Please sign in to create folders.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return; // caller should ensure auth
|
||||
}
|
||||
try {
|
||||
const res = await apiRequest("POST", "/api/cloud-storage/folders", {
|
||||
userId,
|
||||
name,
|
||||
parentId,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Create failed");
|
||||
setIsNewOpen(false);
|
||||
toast({ title: "Folder created" });
|
||||
// refresh this page and top-level recent
|
||||
loadPage(1);
|
||||
setCurrentPage(1);
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Create failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// rename
|
||||
function openRename(folder: CloudFolder) {
|
||||
setRenameTargetId(Number(folder.id));
|
||||
setRenameInitial(folder.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function submitRename(newName: string) {
|
||||
if (!renameTargetId) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/cloud-storage/folders/${renameTargetId}`,
|
||||
{ name: newName }
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Rename failed");
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
toast({ title: "Folder renamed" });
|
||||
|
||||
loadPage(currentPage);
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Rename failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
function openDelete(folder: CloudFolder) {
|
||||
setDeleteTarget(folder);
|
||||
setIsDeleteOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/cloud-storage/folders/${deleteTarget.id}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Delete failed");
|
||||
// deselect if needed
|
||||
if (selectedId === deleteTarget.id) {
|
||||
setSelectedId(null);
|
||||
onSelect?.(null);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({ title: "Folder deleted" });
|
||||
|
||||
// reload current page (if empty page and not first, move back)
|
||||
const maybePage = Math.max(1, currentPage);
|
||||
loadPage(maybePage);
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(total, currentPage * pageSize);
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Folders</CardTitle>
|
||||
<CardDescription>Manage all its Child folders</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="inline-flex items-center px-4 py-2"
|
||||
onClick={() => setIsNewOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Folder
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
||||
{data.map((f) => {
|
||||
const isSelected = selectedId === f.id;
|
||||
return (
|
||||
<div key={f.id} className="flex">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTileClick(Number(f.id))}
|
||||
onContextMenu={(e) => showMenu(e, f)}
|
||||
className={
|
||||
"w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer " +
|
||||
(isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "")
|
||||
}
|
||||
>
|
||||
<FolderIcon className="h-6 w-6 text-yellow-500" />
|
||||
<div className="text-sm truncate">{f.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination inside card */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of {total} results
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage((p) => Math.max(1, p - 1));
|
||||
}}
|
||||
className={
|
||||
currentPage === 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers(currentPage, totalPages).map(
|
||||
(page, idx) => (
|
||||
<PaginationItem key={idx}>
|
||||
{page === "..." ? (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage((p) => Math.min(totalPages, p + 1));
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* react-contexify menu */}
|
||||
<Menu id="folder-section-menu" animation="fade">
|
||||
<Item onClick={({ props }: any) => openRename(props.folder)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<EditIcon className="h-4 w-4" /> Rename
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item onClick={({ props }: any) => openDelete(props.folder)}>
|
||||
<span className="flex items-center gap-2 text-red-600">
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</span>
|
||||
</Item>
|
||||
</Menu>
|
||||
|
||||
{/* Modals */}
|
||||
<NewFolderModal
|
||||
isOpen={isNewOpen}
|
||||
onClose={() => setIsNewOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<NewFolderModal
|
||||
isOpen={isRenameOpen}
|
||||
initialName={renameInitial}
|
||||
title="Rename Folder"
|
||||
submitLabel="Rename"
|
||||
onClose={() => {
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
}}
|
||||
onSubmit={submitRename}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
entityName={deleteTarget?.name}
|
||||
onCancel={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable file
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Folder, Search as SearchIcon } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { CloudFolder } from "@repo/db/types";
|
||||
import FolderPanel from "@/components/cloud-storage/folder-panel";
|
||||
|
||||
// -----------------------------
|
||||
// Reusable NewFolderModal
|
||||
// -----------------------------
|
||||
export type NewFolderModalProps = {
|
||||
isOpen: boolean;
|
||||
initialName?: string;
|
||||
title?: string;
|
||||
submitLabel?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function NewFolderModal({
|
||||
isOpen,
|
||||
initialName = "",
|
||||
title = "New Folder",
|
||||
submitLabel = "Create",
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: NewFolderModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(initialName);
|
||||
}, [initialName, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => {
|
||||
if (!isSubmitting) onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-lg shadow-lg">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onSubmit(name.trim());
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="p-4 space-y-3">
|
||||
<label className="block text-sm font-medium">Folder name</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="Enter folder name"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => !isSubmitting && onClose()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !name.trim()}>
|
||||
{isSubmitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable file
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable file
@@ -0,0 +1,353 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { EditIcon, Folder, Trash2 } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { CloudFolder } from "@repo/db/types";
|
||||
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
} from "@/components/ui/pagination";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
|
||||
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||
import { Menu, Item, contextMenu } from "react-contexify";
|
||||
import "react-contexify/dist/ReactContexify.css";
|
||||
|
||||
export const recentTopLevelFoldersQueryKey = (page: number): QueryKey => [
|
||||
"/api/cloud-storage/folders/recent",
|
||||
page,
|
||||
];
|
||||
|
||||
export type RecentTopLevelFoldersCardProps = {
|
||||
pageSize?: number;
|
||||
initialPage?: number;
|
||||
className?: string;
|
||||
onSelect?: (folderId: number | null) => void;
|
||||
};
|
||||
|
||||
export default function RecentTopLevelFoldersCard({
|
||||
pageSize = 10,
|
||||
initialPage = 1,
|
||||
className,
|
||||
onSelect,
|
||||
}: RecentTopLevelFoldersCardProps) {
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const [renameInitialName, setRenameInitialName] = useState<string>("");
|
||||
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(null);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
data: recentFoldersData,
|
||||
isLoading: isLoadingRecentFolders,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: recentTopLevelFoldersQueryKey(currentPage),
|
||||
queryFn: async () => {
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/folders/recent?limit=${pageSize}&offset=${offset}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok)
|
||||
throw new Error(json?.message || "Failed to load recent folders");
|
||||
|
||||
const data: CloudFolder[] = Array.isArray(json.data) ? json.data : [];
|
||||
const totalCount =
|
||||
typeof json.totalCount === "number"
|
||||
? json.totalCount
|
||||
: typeof json.total === "number"
|
||||
? json.total
|
||||
: data.length;
|
||||
|
||||
return { data, totalCount };
|
||||
},
|
||||
});
|
||||
|
||||
const data = recentFoldersData?.data ?? [];
|
||||
const totalCount = recentFoldersData?.totalCount ?? data.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const startItem = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(totalCount, currentPage * pageSize);
|
||||
|
||||
// toggle selection: select if different, deselect if same
|
||||
function handleTileClick(id: number) {
|
||||
if (selectedFolderId === id) {
|
||||
setSelectedFolderId(null);
|
||||
onSelect?.(null);
|
||||
} else {
|
||||
setSelectedFolderId(id);
|
||||
onSelect?.(id);
|
||||
}
|
||||
// close any open context menu
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
// show react-contexify menu on right-click
|
||||
function handleContextMenu(e: React.MouseEvent, folder: CloudFolder) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
contextMenu.show({
|
||||
id: "recent-folder-context-menu",
|
||||
event: e.nativeEvent,
|
||||
props: { folder },
|
||||
});
|
||||
}
|
||||
|
||||
// rename flow
|
||||
function openRename(folder: CloudFolder) {
|
||||
setRenameTargetId(Number(folder.id));
|
||||
setRenameInitialName(folder.name ?? "");
|
||||
setIsRenameOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
async function handleRenameSubmit(newName: string) {
|
||||
if (!renameTargetId) return;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"PUT",
|
||||
`/api/cloud-storage/folders/${renameTargetId}`,
|
||||
{
|
||||
name: newName,
|
||||
}
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Failed to rename folder");
|
||||
toast({ title: "Folder renamed" });
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
// refresh current page & first page
|
||||
qc.invalidateQueries({
|
||||
queryKey: recentTopLevelFoldersQueryKey(currentPage),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
toast({ title: "Error", description: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// delete flow
|
||||
function openDelete(folder: CloudFolder) {
|
||||
setDeleteTarget(folder);
|
||||
setIsDeleteOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteTarget) return;
|
||||
const id = deleteTarget.id;
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"DELETE",
|
||||
`/api/cloud-storage/folders/${id}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Failed to delete folder");
|
||||
toast({ title: "Folder deleted" });
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
// if the deleted folder was selected, deselect it and notify parent
|
||||
if (selectedFolderId === id) {
|
||||
setSelectedFolderId(null);
|
||||
onSelect?.(null);
|
||||
}
|
||||
// refresh pages
|
||||
qc.invalidateQueries({
|
||||
queryKey: recentTopLevelFoldersQueryKey(currentPage),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
toast({ title: "Error", description: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Folders</CardTitle>
|
||||
<CardDescription>
|
||||
Most recently updated top-level folders.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="py-3">
|
||||
{isLoadingRecentFolders ? (
|
||||
<div className="py-6 text-center">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
||||
{data.map((f) => {
|
||||
const isSelected = selectedFolderId === Number(f.id);
|
||||
return (
|
||||
<div key={f.id} className="flex">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTileClick(Number(f.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
handleTileClick(Number(f.id));
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, f)}
|
||||
className={
|
||||
"w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer focus:outline-none " +
|
||||
(isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "")
|
||||
}
|
||||
style={{ minHeight: 44 }}
|
||||
>
|
||||
<Folder className="h-8 w-8 text-yellow-500 flex-shrink-0" />
|
||||
<div className="text-sm truncate">{f.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing {startItem}–{endItem} of {totalCount} results
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1)
|
||||
setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers(currentPage, totalPages).map(
|
||||
(page, idx) => (
|
||||
<PaginationItem key={idx}>
|
||||
{page === "..." ? (
|
||||
<span className="px-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(page as number);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
)}
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* react-contexify Menu (single shared menu) */}
|
||||
<Menu id="recent-folder-context-menu" animation="fade">
|
||||
<Item
|
||||
onClick={({ props }: any) => {
|
||||
const folder: CloudFolder | undefined = props?.folder;
|
||||
if (folder) openRename(folder);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<EditIcon className="h-4 w-4" /> Rename
|
||||
</span>
|
||||
</Item>
|
||||
|
||||
<Item
|
||||
onClick={({ props }: any) => {
|
||||
const folder: CloudFolder | undefined = props?.folder;
|
||||
if (folder) openDelete(folder);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-red-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</span>
|
||||
</Item>
|
||||
</Menu>
|
||||
|
||||
{/* Rename modal (reuses NewFolderModal) */}
|
||||
<NewFolderModal
|
||||
isOpen={isRenameOpen}
|
||||
initialName={renameInitialName}
|
||||
title="Rename Folder"
|
||||
submitLabel="Rename"
|
||||
onClose={() => {
|
||||
setIsRenameOpen(false);
|
||||
setRenameTargetId(null);
|
||||
}}
|
||||
onSubmit={async (name) => {
|
||||
await handleRenameSubmit(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
entityName={deleteTarget?.name}
|
||||
onCancel={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable file
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable file
@@ -0,0 +1,426 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Folder as FolderIcon,
|
||||
File as FileIcon,
|
||||
Search as SearchIcon,
|
||||
Clock as ClockIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Canonical query keys
|
||||
*/
|
||||
export const cloudSearchQueryKeyRoot = ["cloud-search"];
|
||||
|
||||
export const cloudSearchQueryKeyBase = (
|
||||
q: string,
|
||||
searchTarget: "filename" | "foldername" | "both",
|
||||
typeFilter: "any" | "images" | "pdf" | "video" | "audio",
|
||||
page: number
|
||||
) => ["cloud-search", q, searchTarget, typeFilter, page];
|
||||
|
||||
type ResultRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
mimeType?: string | null;
|
||||
folderId?: number | null;
|
||||
isComplete?: boolean;
|
||||
kind: "file" | "folder";
|
||||
fileSize?: string | number | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export default function CloudSearchBar({
|
||||
onOpenFolder = (id: number | null) => {},
|
||||
onSelectFile = (fileId: number) => {},
|
||||
}: {
|
||||
onOpenFolder?: (id: number | null) => void;
|
||||
onSelectFile?: (fileId: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [searchTarget, setSearchTarget] = useState<
|
||||
"filename" | "foldername" | "both"
|
||||
>("filename"); // default filename
|
||||
const [typeFilter, setTypeFilter] = useState<
|
||||
"any" | "images" | "pdf" | "video" | "audio"
|
||||
>("any");
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(10);
|
||||
|
||||
const debounceMs = 600;
|
||||
const [debouncedQ, setDebouncedQ] = useState(q);
|
||||
|
||||
// debounce input
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQ(q.trim()), debounceMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [q, debounceMs]);
|
||||
|
||||
function typeParamFromFilter(filter: string) {
|
||||
if (filter === "any") return undefined;
|
||||
if (filter === "images") return "image";
|
||||
if (filter === "pdf") return "application/pdf";
|
||||
return filter;
|
||||
}
|
||||
|
||||
// fetcher used by useQuery
|
||||
async function fetchSearch(): Promise<{
|
||||
results: ResultRow[];
|
||||
total: number;
|
||||
}> {
|
||||
const query = debouncedQ ?? "";
|
||||
if (!query) return { results: [], total: 0 };
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const typeParam = typeParamFromFilter(typeFilter as string);
|
||||
|
||||
// helper: call files endpoint
|
||||
async function callFiles() {
|
||||
const tQuery = typeParam ? `&type=${encodeURIComponent(typeParam)}` : "";
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/search/files?q=${encodeURIComponent(query)}${tQuery}&limit=${limit}&offset=${offset}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "File search failed");
|
||||
const mapped: ResultRow[] = (json.data || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
kind: "file",
|
||||
mimeType: d.mimeType,
|
||||
fileSize: d.fileSize,
|
||||
folderId: d.folderId ?? null,
|
||||
createdAt: d.createdAt,
|
||||
}));
|
||||
return { mapped, total: json.totalCount ?? mapped.length };
|
||||
}
|
||||
|
||||
// helper: call folders endpoint
|
||||
async function callFolders() {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/search/folders?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || "Folder search failed");
|
||||
const mapped: ResultRow[] = (json.data || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
kind: "folder",
|
||||
folderId: d.parentId ?? null,
|
||||
}));
|
||||
// enforce top-level folders only when searching folders specifically
|
||||
// (if the API already filters, this is harmless)
|
||||
return { mapped, total: json.totalCount ?? mapped.length };
|
||||
}
|
||||
|
||||
// Decide which endpoints to call
|
||||
if (searchTarget === "filename") {
|
||||
const f = await callFiles();
|
||||
return { results: f.mapped, total: f.total };
|
||||
} else if (searchTarget === "foldername") {
|
||||
const fo = await callFolders();
|
||||
// filter top-level only (parentId === null)
|
||||
const topLevel = fo.mapped.filter((r) => r.folderId == null);
|
||||
return { results: topLevel, total: fo.total };
|
||||
} else {
|
||||
// both: call both and combine (folders first, then files), but keep page limit
|
||||
const [filesRes, foldersRes] = await Promise.all([
|
||||
callFiles(),
|
||||
callFolders(),
|
||||
]);
|
||||
// folders restrict to top-level
|
||||
const foldersTop = foldersRes.mapped.filter((r) => r.folderId == null);
|
||||
const combined = [...foldersTop, ...filesRes.mapped].slice(0, limit);
|
||||
const combinedTotal = foldersRes.total + filesRes.total;
|
||||
return { results: combined, total: combinedTotal };
|
||||
}
|
||||
}
|
||||
|
||||
// react-query: key depends on debouncedQ, searchTarget, typeFilter, page
|
||||
const queryKey = useMemo(
|
||||
() => cloudSearchQueryKeyBase(debouncedQ, searchTarget, typeFilter, page),
|
||||
[debouncedQ, searchTarget, typeFilter, page]
|
||||
);
|
||||
|
||||
const { data, isFetching, error } = useQuery({
|
||||
queryKey,
|
||||
queryFn: fetchSearch,
|
||||
enabled: debouncedQ.length > 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// sync local UI state with query data
|
||||
const results = data?.results ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const loading = isFetching;
|
||||
const errMsg = error ? ((error as any)?.message ?? String(error)) : null;
|
||||
|
||||
// persist recent terms & matches when new results arrive
|
||||
useEffect(() => {
|
||||
if (!debouncedQ) return;
|
||||
// recent terms
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_terms");
|
||||
const prev: string[] = raw ? JSON.parse(raw) : [];
|
||||
const term = debouncedQ;
|
||||
const copy = [term, ...prev.filter((t) => t !== term)].slice(0, 10);
|
||||
localStorage.setItem("cloud_search_recent_terms", JSON.stringify(copy));
|
||||
} catch {}
|
||||
|
||||
// recent matches snapshot
|
||||
try {
|
||||
const rawMatches = localStorage.getItem("cloud_search_recent_matches");
|
||||
const prevMatches: Record<string, ResultRow[]> = rawMatches
|
||||
? JSON.parse(rawMatches)
|
||||
: {};
|
||||
const snapshot = results;
|
||||
const copy = { ...prevMatches, [debouncedQ]: snapshot };
|
||||
localStorage.setItem("cloud_search_recent_matches", JSON.stringify(copy));
|
||||
} catch {}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, debouncedQ]);
|
||||
|
||||
// load recentTerms & recentMatches from storage for initial UI
|
||||
const [recentTerms, setRecentTerms] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_terms");
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [recentMatches, setRecentMatches] = useState<
|
||||
Record<string, ResultRow[]>
|
||||
>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_matches");
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// update recentTerms/recentMatches UI copies whenever localStorage changes (best-effort)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_terms");
|
||||
setRecentTerms(raw ? JSON.parse(raw) : []);
|
||||
} catch {}
|
||||
try {
|
||||
const raw = localStorage.getItem("cloud_search_recent_matches");
|
||||
setRecentMatches(raw ? JSON.parse(raw) : {});
|
||||
} catch {}
|
||||
}, [data]); // refresh small UX cache when new data arrives
|
||||
|
||||
// reset page when q or filters change (like before)
|
||||
useEffect(() => setPage(1), [debouncedQ, searchTarget, typeFilter]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(total / limit)),
|
||||
[total, limit]
|
||||
);
|
||||
|
||||
function onClear() {
|
||||
setQ("");
|
||||
// the query will auto-disable when debouncedQ is empty
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card p-4 rounded-2xl shadow-sm">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<SearchIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search files and folders..."
|
||||
aria-label="Search files and folders"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => onClear()}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(v) => setSearchTarget(v as any)}
|
||||
value={searchTarget}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Search target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="filename">Filename (default)</SelectItem>
|
||||
<SelectItem value="foldername">
|
||||
Folder name (top-level)
|
||||
</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
onValueChange={(v) => setTypeFilter(v as any)}
|
||||
value={typeFilter}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any type</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="pdf">PDFs</SelectItem>
|
||||
<SelectItem value="video">Videos</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={() => setPage((p) => p)}>Search</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold flex items-center gap-2">
|
||||
<ClockIcon className="h-4 w-4" /> Recent searches
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentTerms.length ? (
|
||||
recentTerms.map((t) => (
|
||||
<motion.button
|
||||
key={t}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="px-3 py-1 rounded-full bg-muted text-sm"
|
||||
onClick={() => setQ(t)}
|
||||
>
|
||||
{t}
|
||||
</motion.button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No recent searches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">Results</h4>
|
||||
|
||||
<div className="bg-background rounded-md p-2 max-h-72 overflow-auto">
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-4 text-center text-sm"
|
||||
>
|
||||
Searching...
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading && errMsg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-4 text-sm text-destructive"
|
||||
>
|
||||
{errMsg}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading && !results.length && debouncedQ && !errMsg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-4 text-sm text-muted-foreground"
|
||||
>
|
||||
No results for "{debouncedQ}"
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
results.map((r) => (
|
||||
<motion.div
|
||||
key={`${r.kind}-${r.id}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="p-2 rounded hover:bg-muted/50 flex items-center gap-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (r.kind === "folder") onOpenFolder(r.id);
|
||||
else onSelectFile(r.id);
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center rounded bg-muted">
|
||||
{r.kind === "folder" ? (
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium">{r.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{r.kind === "file" ? (r.mimeType ?? "file") : "Folder"}
|
||||
</div>
|
||||
</div>
|
||||
{r.kind === "file" && r.fileSize != null && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{String(r.fileSize)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{total} result(s)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
{page} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user