mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 00:47:41 +00:00
3ee00fcf06
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>
102 lines
3.2 KiB
TypeScript
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);
|
|
}
|