Skip to content

Audit Log — Cross-Cutting Who-Did-What Trail

AuditService is a shared, cross-cutting service, not a domain feature. A domain module injects it and appends one immutable event per mutation inside its own Prisma transaction, so the audit row commits atomically with the change it records. Attendance, grades, rosters, assignments, communications, and reports use this pattern. The module also owns one descriptor entity (audit_log) and a grant-gated, tenant-wide read API (seeded for Admin, Director, Communications Manager, and Department Principal — §6).

RBAC primitives: permissions.


1. Mental model

Reports consumer: saved_report.created, .updated, .deleted record configuration type/revision; report_run.queued, .generated, .failed, .exported record operational lifecycle metadata. Generated events include resolved year/date range, counts and snapshot/publication times; exported includes format/count. They never contain report names/descriptions, settings, bindings, columns, cells or file bytes. Existing actor attribution remains standard. Saved/queued/generated/failed events share their owning mutation transaction; export is audited after rendering and a fresh delivery check, before sending bytes. Audit presentation uses generic localized action copy without opening private configuration details or linking another admin to its payload. It does not prove that a client finished saving the file. See reports.

Report-cards consumer: report_card_template.created (a duplicate carries sourceTemplateId), .updated (before/after) and .deleted; report_card_cycle.opened, .updated, .grade_added, .grade_template_changed (with the cleared card ids), .grade_removed, .closed, .reopened, .grades_published and .deleted; report_card.generated (one per card, bulk through recordMany; wasPublished marks a correction) and report_card.corrected (before/after of each changed value slot). Value writes on unpublished cards are not audited; each entry row keeps its last editor instead. data never carries a top-level v, so these value events never reach the structural presenter. Presentation is label-only. See report cards.

Conferences consumer: conference.created, .updated (before/after of what changed), .deleted, .published, .booking_opened, .booking_closed, .participants_added, .participant_removed and .participant_rooms_changed; conference_availability.updated (anchored on the participant; only administrator writes that changed a slot, a teacher's own save is not audited); conference_booking.created and .cancelled (anchored on the booking; cancelled carries cancelledBy and actorUserId). Presentation is label-only. See conferences.

Referents consumer: referent.merged (anchored on the surviving referent; sourceId/targetId, both names and movedStudentIds) and referent.student_unlinked (anchored on referentId, which stays a soft anchor after the referent is deleted; data is { referentId, referentName, studentId, studentName }, names snapshotted in the transaction, no email, relationship or flags). The unlink is recorded on the same transaction as the link delete by both DELETE /referents/:id/students/:studentId and DELETE /students/:id/referents/:referentId; a 404 or 409 appends nothing. DELETE /referents/:id and the CSV re-import are not audited. Presentation references the referent and the student and links to referent.detail only while the referent still exists.

An append-only trail. One AuditLog row per event; domain APIs append events and do not update or delete them. Event emission is explicitly owned by each domain, and reading is all-or-nothing for roles granted audit_log.configuration READ.

Two facts make the design simple:

  • Atomic with the mutation. record() takes the caller's transaction handle (Prisma.TransactionClient) and writes through it. If the domain mutation rolls back, so does its audit row — they share one transaction. There is no fan-out, no post-commit queue, no fail-soft branch.
  • Self-describing & decoupled. entityType and action are free strings (the write API does not validate domain vocabulary). The actor and the changed values are denormalized snapshots captured at write time, so a row stays readable even after the user or the referenced entity is gone.

2. Data model

model AuditLog (prisma/schema.prisma), table audit_logs:

Field Type Notes
id uuid PK
tenantId uuid FK → tenants, onDelete: Restrict tenant-scoped; the FK prevents tenant deletion while audit rows remain
actorUserId uuid? FK → users, onDelete: SetNull nullable; the user who acted. Goes NULL if the user is later deleted — the trail survives
actorName VarChar(200) denormalized name snapshot at write time
actorRole VarChar(200) denormalized role snapshot (see §3)
entityType VarChar(100) free string, e.g. attendance_record
entityId uuid NOT NULL, no FK soft reference — required and UUID-typed, but has no foreign key, so the referenced entity can be hard-deleted without breaking the trail
action VarChar(150) free string, e.g. attendance_record.updated
data Json (NOT NULL) full value snapshot for this event, shape per entityType
requestContext Json? { requestId } lifted from CLS, or null
createdAt DateTime @default(now()) event time; list/history queries order newest-first by this field

Two composite indexes, both tenant-first:

  • (tenantId, entityType, entityId, createdAt) — backs historyFor() and the entity-filtered admin list.
  • (tenantId, actorUserId, createdAt) — backs actor-filtered queries.

entityId is the one detail people get wrong. It is not nullable and not a free string — it is a required UUID. What makes it "soft" is the absence of an FK: nothing constrains it to a live row, so deleting the referenced entity leaves the audit rows intact (and still findable by id). Contrast with actorUserId (nullable, SetNull) and tenantId (FK, Restrict).


3. Writing events

Two calls, both on AuditService (src/audit-log/audit.service.ts):

// once per request, at the top of the transaction
const actor = await this.audit.resolveActor(tx, ctx);
// once per mutation
await this.audit.record(tx, actor, { entityType, entityId, action, data });

resolveActor(tx, ctx: RecordAccessContext) → AuditActor builds the actor snapshot once and is reused across every record() in the same request. It looks up the acting user's name (firstName + lastName, falling back to the raw userId if the lookup is empty) and derives the role string: 'platform_admin' when ctx.isPlatformAdmin, otherwise ctx.roles.join(','). The returned AuditActor = { userId, tenantId, name, role } carries everything record() denormalizes.

record(tx, actor, params: AuditRecordParams) → void appends one row via tx.auditLog.create. It pulls requestId from CLS (ClsService) and stores requestContext: { requestId } when present (else undefined → column null). Because it writes through the caller's tx, the event is part of the caller's atomic unit — never a separate write.

AuditRecordParams (interfaces/audit.interface.ts): { entityType, entityId, action, data: unknown }. data is the full value snapshot; the consumer decides its shape.

Naming convention (by consumers, not enforced)

entityType is a stable string the consumer owns. action is namespaced <entityType>.<verb> — attendance writes attendance_record.created / attendance_record.updated (src/attendance/attendance.service.ts). Nothing in the audit module validates these strings; the convention lives in the consumers — except for structural events, whose vocabulary the audit module owns outright (see below).

Clearing an attendance cell (DELETE /attendance/records) writes no audit row: it is the exception to attendance auditing every mutation, and the history of a cleared cell stops at its last created/updated row.

Disciplinary notes use the value vocabulary disciplinary_note.created, .updated, .withdrawn, and .acknowledged. All four are recorded inside the owning mutation transaction. Sweeper delivery is intentionally not audited because it has no human actor; notifiedAt, firstNotifiedAt, and revision are the machine evidence.

What goes in data

Store the meaningful evidence for the event, using a self-contained snapshot where the domain requires it. Attendance, for instance, stores the cell's value fields (status, time, note) so a history entry is self-contained and readable without joining back to live rows that may have changed or been deleted.


3b. Structural events

Everything above is value audit: "this field changed to that". Temporal domain writes use a second kind — structural audit: "who moved this student to that homeroom, when did we act, and from which date does it apply". Value audit has one time axis (createdAt); a structural event has two.

Two primitives, one service

Value audit Structural audit
Write record / recordMany recordStructuralChange / recordStructuralChanges
data consumer-shaped, free-form the fixed envelope below, built by one internal producer
Vocabulary consumer-owned strings src/audit-log/structural-audit.constants.ts, review-enforced
action check none runtime <entityType>. prefix assert
Time axes createdAt createdAt + effectiveOn

Both live on the same AuditService — a module that emits both kinds injects one service. The write discipline is identical: one create on the caller's tx, no separate transaction, no fail-soft branch, createdAt as the transaction-time stamp.

The envelope

// audit_logs.data for every structural event
{
 "v": 1,
 "before": { /* full row snapshot */ } | null, // null on open/create
 "after": { /* full row snapshot */ } | null, // null on genuine hard removal (rare)
 "effectiveOn": "YYYY-MM-DD" | null, // valid-time application date
 "effectiveTick": 132 | null // optional five-minute boundary within that date
}

All five keys are always present — a fixed shape is what makes the envelope machine-readable without per-key existence checks. v exists so a future semantic change bumps to 2 while old rows stay readable (additive keys do not bump v).

data.v === 1 is the discriminator. Value-audit rows such as attendance_record and the grade_entry operational projection are not structural envelopes. Do not reuse their entity types for structural events. effectiveTick is stored only when an effective date exists; otherwise it is null.

Snapshot rows with toJsonSnapshot (exported from the barrel): it is the sanctioned row → JSON conversion, so Date columns serialize identically everywhere instead of six consumers hand-rolling it. A Prisma row containing Date isn't assignable to Prisma.JsonValue, so the compiler routes you here.

entityId is the stable anchor, not the row you mutated

This inverts §9's recipe rule for structural events. Interval rows churn by design — one student's homeroom history spans many HomeroomAssignment rows — so anchoring on the mutated row id would shatter one fact's history across many ids and make historyFor useless. Anchor on the history key of the fact:

entityType Anchor
student_placement, student_enrolment, homeroom_membership, subject_group_membership, curriculum_selection studentId
subject_group_teacher subjectGroupId
combined_class_membership combinedClassId
timetable the lineage's stable published Timetable id

Row ids are preserved inside the snapshots, so nothing is lost — and (tenantId, entityType, entityId, createdAt), the index that already exists, becomes the exact history key.

One event per logical command

A move emits a single .moved event — before = the outgoing row pre-command, after = the incoming row, effectiveOn = the shared boundary — never a .closed + .opened pair. Same rule for timetable.republished. The outgoing row's post-close state is derivable (before + validUntil = effectiveOn).

Database cascades do not automatically emit child events. A domain requiring their evidence must capture it explicitly in the owning command.

Choose the fact's snapshot boundary

before/after are the affected row's full column set, id included. Some facts deliberately use a projection:

  • student_enrolment → { id, enrollmentDate, exitDate, status }. The Student row spans eight RBAC scopes including health; copying it wholesale into a trail readable through the admin list would bypass field-level filtering for zero forensic value. The affected fact is enrolment.
  • timetable → version metadata (ids, revisionNumber, effectiveFrom/effectiveUntil). The affected fact is the governance interval; the schedule-content evidence is the retained archive rows, which no Timetable-row snapshot could carry.

requestContext

Caller passed Stored
omitted, CLS has requestId { requestId } — same as record()
omitted, no CLS requestId not provided → column NULL
explicit null column NULL (Prisma.DbNull) — deliberate suppression
object shallow-merged over { requestId }, caller keys winning
non-object non-null verbatim (legal, discouraged)

Adding a name

Editing structural-audit.constants.ts is the procedure — an inline structural entityType/action string anywhere else is a review reject, and structural-audit.constants.spec.ts pins the exact sets. The registry includes reserved names; a constant does not establish an implemented command. Use an owning domain's actual write contract.

Envelope rows render through the same presentation resolver as value events; their before/after ids supply the from/to references described in §5.


4. Reading history (consumer-facing)

historyFor(tenantId, entityType, entityId) → AuditLogEntryResponseDto[] returns every event for one entity instance, newest-first. Domain modules call it to surface a typed value-change history — e.g. attendance's per-cell history endpoint returns { current, history }, where history is historyFor(tenantId, 'attendance_record', id) (src/attendance/attendance.service.ts). This read goes through PrismaService and joins an ambient tenant transaction when one is present.

Mapping to the response DTO is centralized in toAuditLogEntryResponse (audit-log.queries.ts); requestContext is normalized to null when absent. historyFor and the admin list both run the same grouped presentation resolver, so embedded history entries carry the same summary, references, primaryLink, labels, and forensic fields described in §5. A link still stays null when its destination cannot be reconstructed or no longer exists.

Grades use their own immutable ledger. GET /grades/:id/history reads GradeEntryRevision directly. grade_entry.* audit events are the operational trail and are not complete grade state. Historical action labels remain readable; grade withdrawal commands emit grade_entry.withdrawn.


5. Read API

Two GETs (src/audit-log/audit-log.controller.ts) — read-only, no write endpoints. Both carry the same guard pair:

@RequireScopes(EntityKey.AUDIT_LOG, 'read')
@AggregateResponse()

No role gate — the scope grant alone governs (see §6). Seeded readers are Admin, Director, Communications Manager, and Department Principal; all receive the same tenant-wide trail. HR, Front Office, Curriculum Coordinator, and profile roles remain ungranted.

GET /audit-log — paginated (PaginationQueryDto), newest-first, tenant-scoped. ListAuditLogQueryDto filters:

Param Semantics
entityType / entityId / actorUserId exact match
action exact match; actions are namespaced <entityType>.<verb>, so the FE passes the matching entityType alongside — the query then enters through the (tenantId, entityType, …) index
search case-insensitive substring on actorName (people-list search idiom)
from / to inclusive createdAt range

buildAuditLogWhere (audit-log.queries.ts) assembles the where.

Entries are render-ready and semantically navigable. On top of the raw columns, each entry carries:

Field Semantics
summary: LocalizedMessage \| null The complete event sentence in {en_US, it_IT}. The FE renders summary[lang] verbatim. Domain projectors provide detailed copy where supported; other catalogued actions can use generic localized copy. Unknown actions without a fallback yield null.
references: AuditReferenceDto[] Every domain object named by the summary or semantic destination. Each item is {role, kind, id, label}: role is event-local (student, from, to, record, subject, and so on), kind is the closed resource vocabulary, and label is a snapshot or tenant-scoped live label when recoverable. A deleted historical object stays as an id with label: null when the row still captured the id.
primaryLink: AuditLinkDto \| null Exact semantic frontend destination: {destination, section, params, query}. It is not an href; the frontend maps destination to its router and forwards the supplied context. Attendance links include date/cohort/student/cell or event context; grade, discipline, communication, referent, roster, curriculum, and timetable events select their relevant screen/section. It is null when a live destination cannot be proven.
target: {kind, id, label} \| null Deprecated compatibility descriptor. Consumers should use primaryLink and references.
entityTypeLabel / actionLabel: LocalizedMessage \| null Short localized vocabulary from ENTITY_LABELS and module-owned AUDIT_ACTION_LABELS. These remain useful for filter options and chips; unknown keys are null and render as their raw keys.
effectiveOn: string \| null Valid-time application date, distinct from createdAt.
effectiveTick: number \| null Five-minute boundary within that date; null for whole-day changes or events without a date.
changes: AuditChangeDto[] \| null Structural events (data.v === 1): generic before/after field diff. Values remain raw forensic evidence; they are separate from the row-summary contract.
details: AuditDetailDto[] \| null Value events: top-level data keys as labeled values. Also forensic/expanded content rather than headline copy.

resolveAuditEntryPresentations (audit-presentation.queries.ts) collects ids for the whole page, performs bounded tenant-scoped IN lookups by resource kind, and projects each action. There is no per-row lookup. Snapshot labels win where the audited event captured them; live tables fill gaps and decide whether a link target still exists. Grade summaries prefer the immutable GradeEntryRevision named by data.revisionId, never generic audit JSON as a grade-state ledger. Missing/deleted references and unknown actions fail soft and never turn the audit read into an error.

Exactly one of changes/details remains non-null, and data stays verbatim for the expandable/debug view. buildAuditEntryReadable owns those query-free forensic fields. The presentation-registry drift spec separately pins the action-label keys to the presentation registry. Registry membership does not imply a specialized semantic link; generic presentations intentionally use null.

GET /audit-log/filters — dropdown vocabulary for the list: the tenant's distinct (entityType, actions[]) pairs via groupBy, alphabetical by key at both levels, each key served with its catalog label (null when uncatalogued). Data-derived on purpose — value-audit vocabulary is consumer-owned free strings with no central registry, so the rows are the only complete source; a new audit consumer surfaces in the dropdowns with zero code here (label null until catalogued).


6. RBAC wiring

audit_log is descriptor-only with one configuration scope and no actions. The read scope is the route gate; there is no additional role decorator.

Admin, Director, Communications Manager, and Department Principal presets receive the trail. Their audit access is tenant-wide, including raw event data; department parameters and person-field scope masks do not narrow it. Other roles require an actual catalogue grant, not an inference from their domain access. See the preset source and reconciliation contract in permissions.

There is no HTTP write endpoint. In-process consumers explicitly append through AuditService. Review payloads with the entire audit-reader audience in mind: do not copy unrelated sensitive person fields or private report content into an event.

7. Module wiring

AuditLogModule (src/audit-log/audit-log.module.ts) imports PrismaModule, declares AuditLogController, and provides + exports AuditService so other modules can inject it. It is registered in src/app.module.ts.

Consumers import from the barrel: import { AuditService } from '../audit-log' (src/audit-log/index.ts re-exports AuditService, AuditLogModule, the AuditActor / AuditRecordParams types, and — for structural consumers — toJsonSnapshot, the STRUCTURAL_* constants and the StructuralChangeInput / StructuralAuditActor / StructuralAuditEnvelope types). A consuming module imports AuditLogModule and injects AuditService in its service constructor (attendance does exactly this). buildStructuralAuditData is deliberately not exported — the envelope has exactly one producer.


8. Explicit emission

Consumers opt into auditing by calling the service in their mutation transaction. A base CRUD write, database cascade, scheduled state transition, or read does not gain an event automatically. Keep the producer and its event semantics visible in the owning module.

9. Recipe — emit audit events from a new module

  1. Import AuditLogModule in your module and inject AuditService (from '../audit-log').
  2. Do your mutation inside a this.prisma.$transaction(async (tx) => { ... }).
  3. At the top of the tx, call const actor = await this.audit.resolveActor(tx, ctx) once (you need a RecordAccessContext — the ctx your controller already passes for tenant/user/roles).
  4. After each write, call await this.audit.record(tx, actor, { entityType, entityId, action, data }):
  5. entityType — a stable string for your domain (e.g. grade_record).
  6. action — '<entityType>.<verb>' (e.g. grade_record.created).
  7. entityId — the UUID of the row you mutated (the soft reference).
  8. data — a full snapshot of the meaningful values for this event, including any denormalized labels you'd want readable after related rows change or are deleted.
  9. Register presentation and vocabulary with the producer: add the action to AUDIT_ACTION_LABELS, add the entity spelling to ENTITY_LABELS when new, and add the action to the projector in audit-presentation.queries.ts with its localized summary, typed references, and semantic link. The drift spec intentionally fails when the action catalog and presentation registry diverge.
  10. (Optional) To expose a value-change history for one instance, call this.audit.historyFor(tenantId, entityType, id) from a read method and return it alongside the current state (see attendance's { current, history } cell endpoint).
  11. If the action needs facts not recoverable after mutation (especially on a hard delete), snapshot the ids and display names in data at the producer. Extend AUDIT_REFERENCE_KINDS or AUDIT_LINK_DESTINATIONS only when the existing vocabularies cannot describe the resource/screen. Do not emit a concrete href from the backend.

The GET /audit-log surface picks up appended events automatically (filterable by your entityType, discoverable via GET /audit-log/filters).

10. Class-register value vocabulary

The assignments module emits assignment.created with the complete original assignment snapshot including initial attachments, assignment.updated with complete before/after business snapshots including current attachments, and assignment.deleted with the complete pre-delete aggregate snapshot. The delete event survives the hard-deleted row. Its shared course-day text emits course_day_lesson_content.created, .updated, and .cleared, with before/ after text plus frozen course-day and actor context. Every material write and its event share one transaction; same-value and clear-of-absent no-ops, reads, deadline passage, and audience changes emit nothing. Admin-authored values use the real Admin user/name and a null Teacher id. The localized action and entity labels are registered in the audit catalogs and the actions participate in the presentation registry. Assignment events currently use the generic presentation and therefore fail soft with no resource link when the row has been hard-deleted.

Post-create attachment mutations use assignment.attachments_added (one event per atomic file/link batch), assignment.attachment_updated (public before/after LINK metadata), and assignment.attachment_deleted (public removed-item metadata). They retain the Assignment as entityId; no child event is emitted during parent hard delete. Audit payloads may contain the public link URL and label or file name/MIME/byte size, but never an internal File id, storage key, signed URL, uploader id, or file content. A normalized link no-op is quiet.

Historical action and evidence compatibility

Current roster saves do not emit combined_class.enrolment_confirmed. Its action identifier, localized labels and historical presentation remain so already recorded events stay readable. Ordinary roster/selection/structural audits continue under their original actor and transaction.

Existing attendance attribution arrays and singular legacy snapshots remain raw evidence. The legacy decoder serves authorized audit presentation only; public attendance cells use the physical anchor and omit attributionStatus/courseAttributions. Same-anchor edits preserve evidence; reanchor events retain the old evidence before replacing the snapshot. Preserve raw audit evidence independently of current domain response projections.