Audit Log
Query the append-only audit trail by entity, action, branch, user, search text, and date window.
You trace every sensitive change through the audit_log table in packages/database/schema.sql. Writers call logAction from apps/server/src/core/audit/audit.ts, or insertAuditEntry from apps/server/src/core/audit/outbox.ts when the row is written inside a transaction, and you read rows through the admin-only routes in apps/server/src/features/audit/route.ts.
What gets logged
You will find LOGIN and PASSWORD_CHANGE from apps/server/src/features/auth/service.ts, user CREATE, UPDATE, and RESET_PASSWORD from apps/server/src/features/users/service.ts, medicine IMAGE_UPDATE and org LOGO_UPDATE from apps/server/src/features/images/service.ts, changelog CREATE from apps/server/src/features/ops-visibility/service.ts, and sale REPLAY from apps/server/src/features/sync/service.ts when an offline sale is pushed. Each row stores the actor, branch, entity, action, entity id, before and after JSON, IP, and timestamp.
Reading the log
Both audit routes require authentication plus the admin role. You list entries newest first with pagination defaulting to page 1 and 50 rows.
curl "http://localhost:8000/audit?page=1&page_size=50" \
-H "Authorization: Bearer $TOKEN"
You get items, total, page, and page_size. Every item joins the actor full_name as user_name for display.
Filtering by entity and action
You narrow the trail with entity, action, branch_id, and user_id query parameters. Combine them freely to answer questions like who reset passwords on a branch this week.
curl "http://localhost:8000/audit?entity=user&action=RESET_PASSWORD&branch_id=BRANCH_ID" \
-H "Authorization: Bearer $TOKEN"
curl "http://localhost:8000/audit?user_id=USER_ID&action=LOGIN" \
-H "Authorization: Bearer $TOKEN"
Searching and date windows
You full-text search with search, which matches entity, entity id, action, actor name, and both JSON snapshots. You bound the window with from_date and to_date.
curl "http://localhost:8000/audit?search=DEMO10&from_date=2026-01-01&to_date=2026-12-31" \
-H "Authorization: Bearer $TOKEN"
Note the date handling in apps/server/src/features/audit/route.ts: from_date shifts back 3 hours and to_date shifts forward 21 hours, so a plain calendar date covers the full local day.
Reading one entry
You fetch a single row with GET /audit/:auditId. Unlike the list, this endpoint parses before_json and after_json into objects for you, falling back to raw text when a snapshot is not valid JSON.
curl http://localhost:8000/audit/AUDIT_ID \
-H "Authorization: Bearer $TOKEN"
A missing id returns 404 with Audit entry not found.
Writing audit rows from your code
You call logAction with the actor, branch, entity triplet, and optional snapshots. Keep before and after small and free of secrets, since any admin can read them and search matches against them. A failed insert is logged and swallowed by logAction, so a broken audit write never blocks the operation it trails.
import { logAction } from "../../core/audit/audit";
await logAction({
userId: currentUser.sub,
branchId: currentUser.branch_id,
entity: "medicine",
action: "PRICE_CHANGE",
entityId: medicineId,
before: { unit_price: oldPrice },
after: { unit_price: newPrice },
});
Next steps
See who the actors are in /platform/authentication-and-users, how branches scope the trail in /platform/branches, and the full endpoint catalog in /api.