initial commit
This commit is contained in:
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user