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 { 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 { 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 { 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); }