From 75940313a4572d547825d86e7d411598f8c4e8f6 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 7 Jun 2026 19:46:22 +0300 Subject: [PATCH] feat: activity audit log written from all resource routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the activity_log table, a best-effort recordActivity() service and a GET /api/activity feed, and write entries on create/update/delete of patients, notes, appointments, prescriptions and tasks. The Activity page now shows the real audit trail (actor, action, patient context, time); the fabricated signing hashes / approval badges are gone — that vision stays deferred. Co-Authored-By: Claude Opus 4.8 --- backend/drizzle/0005_true_gwen_stacy.sql | 16 + backend/drizzle/meta/0005_snapshot.json | 2000 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema/activity.ts | 29 + backend/src/db/schema/index.ts | 1 + backend/src/index.ts | 3 + backend/src/routes/activity.ts | 17 + backend/src/routes/appointments.ts | 26 + backend/src/routes/notes.ts | 22 + backend/src/routes/patients.ts | 27 + backend/src/routes/prescriptions.ts | 26 + backend/src/routes/tasks.ts | 28 + backend/src/services/activity.ts | 68 + backend/src/types/activity.ts | 21 + .../components/activity/activity-view.tsx | 260 +-- frontend/lib/activity.ts | 27 + 16 files changed, 2436 insertions(+), 142 deletions(-) create mode 100644 backend/drizzle/0005_true_gwen_stacy.sql create mode 100644 backend/drizzle/meta/0005_snapshot.json create mode 100644 backend/src/db/schema/activity.ts create mode 100644 backend/src/routes/activity.ts create mode 100644 backend/src/services/activity.ts create mode 100644 backend/src/types/activity.ts create mode 100644 frontend/lib/activity.ts diff --git a/backend/drizzle/0005_true_gwen_stacy.sql b/backend/drizzle/0005_true_gwen_stacy.sql new file mode 100644 index 0000000..2ed45aa --- /dev/null +++ b/backend/drizzle/0005_true_gwen_stacy.sql @@ -0,0 +1,16 @@ +CREATE TABLE "activity_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "actor_id" text, + "actor_name" text NOT NULL, + "action" text NOT NULL, + "entity_type" text NOT NULL, + "entity_id" text, + "patient_name" text, + "patient_file_number" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "activity_org_created_idx" ON "activity_log" USING btree ("organization_id","created_at"); \ No newline at end of file diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..40f2a94 --- /dev/null +++ b/backend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2000 @@ +{ + "id": "1b314b44-b15d-4260-8e23-ae049f317ec9", + "prevId": "d20e2d41-0f3c-41e1-a0cd-01136ec8fb02", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prescriptions": { + "name": "prescriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescriber": { + "name": "prescriber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescribed_at": { + "name": "prescribed_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prescriptions_org_idx": { + "name": "prescriptions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prescriptions_organization_id_organization_id_fk": { + "name": "prescriptions_organization_id_organization_id_fk", + "tableFrom": "prescriptions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prescriptions_created_by_user_id_fk": { + "name": "prescriptions_created_by_user_id_fk", + "tableFrom": "prescriptions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index ea07c00..384acb2 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1780850364853, "tag": "0004_dizzy_scarlet_spider", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1780850717058, + "tag": "0005_true_gwen_stacy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/activity.ts b/backend/src/db/schema/activity.ts new file mode 100644 index 0000000..1175b9e --- /dev/null +++ b/backend/src/db/schema/activity.ts @@ -0,0 +1,29 @@ +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +import type { ActivityEntityType } from "../../types/activity.js"; +import { organization, user } from "./auth.js"; + +// One row per record change, scoped to a clinic (organization). Written +// best-effort from the resource routes (patients / notes / appointments / +// prescriptions / tasks). `actorName` is denormalized so the feed renders even +// after the user is removed (FK is set null). +export const activityLog = pgTable( + "activity_log", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + actorId: text("actor_id").references(() => user.id, { + onDelete: "set null", + }), + actorName: text("actor_name").notNull(), + action: text("action").notNull(), + entityType: text("entity_type").$type().notNull(), + entityId: text("entity_id"), + patientName: text("patient_name"), + patientFileNumber: text("patient_file_number"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [index("activity_org_created_idx").on(t.organizationId, t.createdAt)], +); diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 271e823..940c7ff 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -4,3 +4,4 @@ export * from "./notes.js"; export * from "./appointments.js"; export * from "./prescriptions.js"; export * from "./tasks.js"; +export * from "./activity.js"; diff --git a/backend/src/index.ts b/backend/src/index.ts index 8c04060..ddfce99 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,7 @@ import express from "express"; import { auth } from "./auth.js"; import { env } from "./env.js"; import { errorHandler, notFound } from "./middleware/error.js"; +import { activityRouter } from "./routes/activity.js"; import { appointmentsRouter } from "./routes/appointments.js"; import { notesRouter } from "./routes/notes.js"; import { patientsRouter } from "./routes/patients.js"; @@ -51,6 +52,7 @@ app.use("/api/notes", notesRouter); app.use("/api/appointments", appointmentsRouter); app.use("/api/prescriptions", prescriptionsRouter); app.use("/api/tasks", tasksRouter); +app.use("/api/activity", activityRouter); app.use(notFound); app.use(errorHandler); @@ -63,4 +65,5 @@ app.listen(env.PORT, () => { console.log(` • appts: /api/appointments`); console.log(` • rx: /api/prescriptions`); console.log(` • tasks: /api/tasks`); + console.log(` • activity: /api/activity`); }); diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts new file mode 100644 index 0000000..68c294e --- /dev/null +++ b/backend/src/routes/activity.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; + +import { requireAuth, requireOrg } from "../middleware/auth.js"; +import * as service from "../services/activity.js"; + +export const activityRouter = Router(); + +// The audit feed is readable by any clinic member. +activityRouter.use(requireAuth, requireOrg); + +activityRouter.get("/", async (req, res, next) => { + try { + res.json(await service.listActivity(req.organizationId!)); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/routes/appointments.ts b/backend/src/routes/appointments.ts index fbe465c..0fd1bf4 100644 --- a/backend/src/routes/appointments.ts +++ b/backend/src/routes/appointments.ts @@ -7,6 +7,7 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; import * as service from "../services/appointments.js"; export const appointmentsRouter = Router(); @@ -37,6 +38,15 @@ appointmentsRouter.post( req.user!.id, input, ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Scheduled appointment for ${created.name} on ${created.date}`, + entityType: "appointment", + entityId: created.id, + patientName: created.name, + patientFileNumber: created.fileNumber || null, + }); res.status(201).json(created); } catch (err) { next(err); @@ -56,6 +66,15 @@ appointmentsRouter.put( input, ); if (!updated) throw new HttpError(404, "Appointment not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated appointment for ${updated.name}`, + entityType: "appointment", + entityId: updated.id, + patientName: updated.name, + patientFileNumber: updated.fileNumber || null, + }); res.json(updated); } catch (err) { next(err); @@ -73,6 +92,13 @@ appointmentsRouter.delete( req.params.id as string, ); if (!ok) throw new HttpError(404, "Appointment not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Deleted appointment", + entityType: "appointment", + entityId: req.params.id as string, + }); res.status(204).end(); } catch (err) { next(err); diff --git a/backend/src/routes/notes.ts b/backend/src/routes/notes.ts index 0f55d61..cd5a954 100644 --- a/backend/src/routes/notes.ts +++ b/backend/src/routes/notes.ts @@ -3,6 +3,7 @@ import { Router } from "express"; import { HttpError } from "../lib/http-error.js"; import { noteInputSchema } from "../lib/note-validation.js"; import { requireAuth, requireOrg } from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; import * as service from "../services/notes.js"; export const notesRouter = Router(); @@ -27,6 +28,13 @@ notesRouter.post("/", async (req, res, next) => { req.user!.id, input, ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Created note — ${created.title}`, + entityType: "note", + entityId: created.id, + }); res.status(201).json(created); } catch (err) { next(err); @@ -57,6 +65,13 @@ notesRouter.put("/:id", async (req, res, next) => { input, ); if (!updated) throw new HttpError(404, "Note not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated note — ${updated.title}`, + entityType: "note", + entityId: updated.id, + }); res.json(updated); } catch (err) { next(err); @@ -71,6 +86,13 @@ notesRouter.delete("/:id", async (req, res, next) => { req.params.id as string, ); if (!ok) throw new HttpError(404, "Note not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Deleted note", + entityType: "note", + entityId: req.params.id as string, + }); res.status(204).end(); } catch (err) { next(err); diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index 5137b75..91bff90 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -7,6 +7,7 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; import * as service from "../services/patients.js"; export const patientsRouter = Router(); @@ -54,6 +55,15 @@ patientsRouter.post( req.user!.id, input, ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Created patient ${created.name}`, + entityType: "patient", + entityId: created.fileNumber, + patientName: created.name, + patientFileNumber: created.fileNumber, + }); res.status(201).json(created); } catch (err) { next(err); @@ -73,6 +83,15 @@ patientsRouter.put( input, ); if (!updated) throw new HttpError(404, "Patient not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated patient ${updated.name}`, + entityType: "patient", + entityId: updated.fileNumber, + patientName: updated.name, + patientFileNumber: updated.fileNumber, + }); res.json(updated); } catch (err) { next(err); @@ -90,6 +109,14 @@ patientsRouter.delete( req.params.fileNumber as string, ); if (!ok) throw new HttpError(404, "Patient not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Deleted patient #${req.params.fileNumber}`, + entityType: "patient", + entityId: req.params.fileNumber as string, + patientFileNumber: req.params.fileNumber as string, + }); res.status(204).end(); } catch (err) { next(err); diff --git a/backend/src/routes/prescriptions.ts b/backend/src/routes/prescriptions.ts index 86d3058..7a62b30 100644 --- a/backend/src/routes/prescriptions.ts +++ b/backend/src/routes/prescriptions.ts @@ -7,6 +7,7 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; import * as service from "../services/prescriptions.js"; export const prescriptionsRouter = Router(); @@ -38,6 +39,15 @@ prescriptionsRouter.post( req.user!.id, input, ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Prescribed ${created.medication} for ${created.name}`, + entityType: "prescription", + entityId: created.id, + patientName: created.name, + patientFileNumber: created.fileNumber || null, + }); res.status(201).json(created); } catch (err) { next(err); @@ -58,6 +68,15 @@ prescriptionsRouter.put( input, ); if (!updated) throw new HttpError(404, "Prescription not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated prescription — ${updated.medication}`, + entityType: "prescription", + entityId: updated.id, + patientName: updated.name, + patientFileNumber: updated.fileNumber || null, + }); res.json(updated); } catch (err) { next(err); @@ -75,6 +94,13 @@ prescriptionsRouter.delete( req.params.id as string, ); if (!ok) throw new HttpError(404, "Prescription not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Deleted prescription", + entityType: "prescription", + entityId: req.params.id as string, + }); res.status(204).end(); } catch (err) { next(err); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index dae6d1c..a409809 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -7,6 +7,7 @@ import { requireOrg, requirePermission, } from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; import * as service from "../services/tasks.js"; export const tasksRouter = Router(); @@ -36,6 +37,13 @@ tasksRouter.post( req.user!.id, input, ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Created task — ${created.title}`, + entityType: "task", + entityId: created.id, + }); res.status(201).json(created); } catch (err) { next(err); @@ -55,6 +63,19 @@ tasksRouter.patch( patch, ); if (!updated) throw new HttpError(404, "Task not found."); + const action = + patch.done === undefined + ? `Updated task — ${updated.title}` + : patch.done + ? `Completed task — ${updated.title}` + : `Reopened task — ${updated.title}`; + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action, + entityType: "task", + entityId: updated.id, + }); res.json(updated); } catch (err) { next(err); @@ -72,6 +93,13 @@ tasksRouter.delete( req.params.id as string, ); if (!ok) throw new HttpError(404, "Task not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Deleted task", + entityType: "task", + entityId: req.params.id as string, + }); res.status(204).end(); } catch (err) { next(err); diff --git a/backend/src/services/activity.ts b/backend/src/services/activity.ts new file mode 100644 index 0000000..132a863 --- /dev/null +++ b/backend/src/services/activity.ts @@ -0,0 +1,68 @@ +import { 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); + } +} + +export async function listActivity( + orgId: string, + limit = 100, +): Promise { + const rows = await db + .select() + .from(activityLog) + .where(eq(activityLog.organizationId, orgId)) + .orderBy(desc(activityLog.createdAt)) + .limit(limit); + return rows.map(toEntry); +} diff --git a/backend/src/types/activity.ts b/backend/src/types/activity.ts new file mode 100644 index 0000000..50edf4d --- /dev/null +++ b/backend/src/types/activity.ts @@ -0,0 +1,21 @@ +// The canonical activity-log entry returned by the API. A plain, tamper-evident +// audit trail of record changes within a clinic. (The blockchain-style signing / +// patient-approval flow from the product vision is separate and not built yet.) +export type ActivityEntityType = + | "patient" + | "note" + | "appointment" + | "prescription" + | "task"; + +export type ActivityEntry = { + id: string; + actorName: string; + actorInitials: string; + action: string; + entityType: ActivityEntityType; + entityId: string | null; + patientName: string | null; + patientFileNumber: string | null; + createdAt: string; +}; diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx index 8376aea..7eeee22 100644 --- a/frontend/components/activity/activity-view.tsx +++ b/frontend/components/activity/activity-view.tsx @@ -1,109 +1,59 @@ "use client"; import { - Clock, + Activity as ActivityIcon, + CalendarClock, + CalendarDays, FileText, Hash, + ListChecks, type LucideIcon, NotebookPen, Pill, - ShieldCheck, Stethoscope, - TriangleAlert, } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; +import { + type ActivityEntityType, + type ActivityEntry, + listActivity, +} from "@/lib/activity"; import { cn } from "@/lib/utils"; -// All entries here are mock/placeholder data — there is no signing/ledger -// backend yet. They illustrate temetro's patient-owned, signed-change vision: -// every record change is signed (blockchain-style) and awaits patient approval. +// A plain, tamper-evident audit log of record changes in the active clinic. (The +// blockchain-style signing / patient-approval flow from the product vision is +// separate and not built yet.) -type ActivityStatus = "signed" | "pending"; - -type ActivityEntry = { - id: string; - actor: string; - initials: string; - action: string; - patient: string; - fileNumber: string; - time: string; - hash: string; - status: ActivityStatus; - icon: LucideIcon; +const entityIcon: Record = { + patient: Stethoscope, + note: NotebookPen, + appointment: CalendarClock, + prescription: Pill, + task: ListChecks, }; -const entries: ActivityEntry[] = [ - { - id: "1", - actor: "Dr. Okafor", - initials: "DO", - action: "Updated vitals", - patient: "Amina Yusuf", - fileNumber: "10293", - time: "Today, 10:24", - hash: "0x9f3a…c21", - status: "pending", - icon: Stethoscope, - }, - { - id: "2", - actor: "Dr. Okafor", - initials: "DO", - action: "Added prescription — Lisinopril 10mg", - patient: "Amina Yusuf", - fileNumber: "10293", - time: "Today, 10:21", - hash: "0x4b8e…7df", - status: "pending", - icon: Pill, - }, - { - id: "3", - actor: "Dr. Stein", - initials: "DS", - action: "Created note — Lab review", - patient: "Leila Haddad", - fileNumber: "10342", - time: "Today, 09:48", - hash: "0x1c07…a90", - status: "signed", - icon: NotebookPen, - }, - { - id: "4", - actor: "Dr. Stein", - initials: "DS", - action: "Edited allergies — added Penicillin", - patient: "Daniel Mensah", - fileNumber: "10311", - time: "Yesterday, 16:05", - hash: "0xab12…44e", - status: "signed", - icon: TriangleAlert, - }, - { - id: "5", - actor: "Dr. Okafor", - initials: "DO", - action: "Imported prior records", - patient: "Carlos Rivera", - fileNumber: "10358", - time: "Yesterday, 14:30", - hash: "0x77f0…b3c", - status: "signed", - icon: FileText, - }, -]; - -const kpis = [ - { label: "Pending approvals", value: "2", icon: Clock }, - { label: "Signed today", value: "1", icon: ShieldCheck }, - { label: "Changes this week", value: "37", icon: Hash }, -]; +// ISO timestamp -> "Today, 10:24" / "Yesterday, 16:05" / "Jun 3, 14:30". +function formatTime(iso: string): string { + const d = new Date(iso); + const time = d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const sameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + if (sameDay(d, today)) return `Today, ${time}`; + if (sameDay(d, yesterday)) return `Yesterday, ${time}`; + return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`; +} function Kpi({ label, @@ -130,13 +80,46 @@ function Kpi({ } export function ActivityView() { + const [entries, setEntries] = useState([]); + + useEffect(() => { + let active = true; + listActivity() + .then((data) => { + if (active) setEntries(data); + }) + .catch(() => { + /* api-client redirects on 401; otherwise leave the feed empty */ + }); + return () => { + active = false; + }; + }, []); + + const kpis = useMemo(() => { + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const startOfWeek = new Date(startOfToday); + startOfWeek.setDate(startOfToday.getDate() - now.getDay()); + const today = entries.filter((e) => new Date(e.createdAt) >= startOfToday); + const week = entries.filter((e) => new Date(e.createdAt) >= startOfWeek); + return [ + { label: "Changes today", value: String(today.length), icon: ActivityIcon }, + { label: "This week", value: String(week.length), icon: CalendarDays }, + { label: "Total recorded", value: String(entries.length), icon: Hash }, + ]; + }, [entries]); + return (

Activity

- A signed, tamper-evident log of record changes awaiting patient - approval. Sample data. + An audit log of record changes across the clinic.

@@ -146,66 +129,59 @@ export function ActivityView() { ))}
-
    - {entries.map((entry, i) => { - const Icon = entry.icon; - const isLast = i === entries.length - 1; - return ( -
  1. -
    -
    - + {entries.length === 0 ? ( +
    + No activity yet. Changes to patients, notes, appointments, + prescriptions and tasks will appear here. +
    + ) : ( +
      + {entries.map((entry, i) => { + const Icon = entityIcon[entry.entityType] ?? FileText; + const isLast = i === entries.length - 1; + const context = [ + entry.actorName, + entry.patientName && + `${entry.patientName}${ + entry.patientFileNumber ? ` (#${entry.patientFileNumber})` : "" + }`, + ] + .filter(Boolean) + .join(" · "); + return ( +
    1. +
      +
      + +
      + {!isLast &&
      }
      - {!isLast &&
      } -
      -
      -
      +
      {entry.action} - {entry.status === "signed" ? ( - - - Signed - - ) : ( - - - Pending approval - - )} -
      -
      - - - {entry.initials} - - - - {entry.actor} · {entry.patient} (#{entry.fileNumber}) - -
      +
      + + + {entry.actorInitials} + + + + {context} + +
      -
      - {entry.time} - - - {entry.hash} - +
      + {formatTime(entry.createdAt)} +
      -
      -
    2. - ); - })} -
    +
  2. + ); + })} +
+ )} ); } diff --git a/frontend/lib/activity.ts b/frontend/lib/activity.ts new file mode 100644 index 0000000..08343f3 --- /dev/null +++ b/frontend/lib/activity.ts @@ -0,0 +1,27 @@ +import { apiFetch } from "@/lib/api-client"; + +// An audit-log entry. Mirrors the backend `src/types/activity.ts`. A plain, +// tamper-evident trail of record changes in the active clinic. (The signing / +// patient-approval vision is separate and not built yet.) +export type ActivityEntityType = + | "patient" + | "note" + | "appointment" + | "prescription" + | "task"; + +export type ActivityEntry = { + id: string; + actorName: string; + actorInitials: string; + action: string; + entityType: ActivityEntityType; + entityId: string | null; + patientName: string | null; + patientFileNumber: string | null; + createdAt: string; +}; + +export function listActivity(): Promise { + return apiFetch("/api/activity"); +}