initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

View 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 },
});
},
};

View 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" },
});
}
};

View 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`);
}
},
};

View 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;

View 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 },
});
},
};

View 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;
}

View 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;
}
},
};

View 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;

View 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 },
});
},
};

View 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;
},
};

View 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;
}
},
};

View 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 };
},
};

View 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;
}
};

View 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),
};
},
};

View 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();
},
};

View 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 } });
},
};

View 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;
}
},
};