Files
temetro/backend/src/services/activity.ts
T
Khalid Abdi 3ee00fcf06 feat: per-patient record history + summary PDF export
Add GET /api/activity/patient/:fileNumber (every audited change on one chart,
newest first, readable by any clinic member) and surface it as a Record
history timeline in the patient detail sheet. Add a Download summary action
that builds a clean, printable one-page clinical summary in the browser
(Save as PDF) — no PDF dependency, nothing leaves the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:31 +03:00

102 lines
3.2 KiB
TypeScript

import { and, desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { activityLog } from "../db/schema/activity.js";
import type { ActivityEntityType, ActivityEntry } from "../types/activity.js";
type ActivityRow = typeof activityLog.$inferSelect;
// Up to two-letter initials from a display name (e.g. "Dr. Ada Okafor" -> "AO").
export function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
}
function toEntry(row: ActivityRow): ActivityEntry {
return {
id: row.id,
actorName: row.actorName,
actorInitials: initialsOf(row.actorName),
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
patientName: row.patientName,
patientFileNumber: row.patientFileNumber,
createdAt: row.createdAt.toISOString(),
};
}
// Best-effort: an audit entry must never fail the originating request.
export async function recordActivity(params: {
orgId: string;
actor: { id?: string | null; name?: string | null };
action: string;
entityType: ActivityEntityType;
entityId?: string | null;
patientName?: string | null;
patientFileNumber?: string | null;
}): Promise<void> {
try {
await db.insert(activityLog).values({
organizationId: params.orgId,
actorId: params.actor.id ?? null,
actorName: params.actor.name?.trim() || "Someone",
action: params.action,
entityType: params.entityType,
entityId: params.entityId ?? null,
patientName: params.patientName ?? null,
patientFileNumber: params.patientFileNumber ?? null,
});
} catch (err) {
console.error("Failed to record activity:", err);
}
}
// Lists every audit entry tied to a single patient (by file number), newest
// first. Unlike the clinic feed this is NOT scoped to one actor: a patient's
// record history should show every clinician who added or changed data on it.
export async function listPatientActivity(
orgId: string,
fileNumber: string,
limit = 100,
): Promise<ActivityEntry[]> {
const rows = await db
.select()
.from(activityLog)
.where(
and(
eq(activityLog.organizationId, orgId),
eq(activityLog.patientFileNumber, fileNumber),
),
)
.orderBy(desc(activityLog.createdAt))
.limit(limit);
return rows.map(toEntry);
}
// Lists the clinic's audit feed. When `actorId` is given, only that user's own
// actions are returned (each employee sees their own activity); admins/owners
// call without it to see the whole clinic.
export async function listActivity(
orgId: string,
options: { actorId?: string; limit?: number } = {},
): Promise<ActivityEntry[]> {
const { actorId, limit = 100 } = options;
const rows = await db
.select()
.from(activityLog)
.where(
actorId
? and(
eq(activityLog.organizationId, orgId),
eq(activityLog.actorId, actorId),
)
: eq(activityLog.organizationId, orgId),
)
.orderBy(desc(activityLog.createdAt))
.limit(limit);
return rows.map(toEntry);
}