You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a reusable audit log that tracks who changed what, when, and via which source — across quotes, characters, sources, and persons — without adding columns to those core tables.
Why a separate table
The core tables (Quotes, Characters, Sources, Persons) must stay simple. Audit data grows without bound and has different access patterns (append-only, queried by entity, never updated). A single polymorphic AuditLog table handles all entity types uniformly and can be extended to new entity types with no schema changes.
Table schema
CREATETABLEAuditLog (
Id TEXTNOT NULLPRIMARY KEY, -- UUID
EntityType TEXTNOT NULL, -- 'quote' | 'character' | 'source' | 'person'
EntityId TEXTNOT NULL, -- UUID of the affected record
ActorType TEXTNOT NULL, -- 'user' | 'enrichment' | 'import' | 'system'
ActorId TEXT, -- user UUID, provider name ('tmdb'), import batch UUID, or null for system
Action TEXTNOT NULL, -- see action vocabulary below
Field TEXT, -- nullable; field name for field-level actions
OldValue TEXT, -- nullable; previous value serialised as string
NewValue TEXT, -- nullable; new value serialised as string
OccurredAt TEXTNOT NULL-- ISO 8601 UTC
);
CREATEINDEXIX_AuditLog_EntityON AuditLog (EntityType, EntityId, OccurredAt DESC);
Field added to noValueKnown — confirmed no value exists (#55)
imported
Record written via bulk import (#45); ActorId = import batch UUID
Actor model
ActorType
ActorId
Scenario
user
User UUID
Manual edit in the management UI
enrichment
Provider name (tmdb, wikidata, etc.)
Enrichment provider filled a field
import
Import batch UUID
Bulk import (#45); batch UUID groups all rows from one import run
system
null
Startup seeding, migrations
The actor model is intentionally forward-compatible: it works before users exist (auth #15) by using ActorType='system' or ActorType='import' as the actor.
Injected wherever writes happen: write endpoints (#16), import endpoint (#45), enrichment service (#19). All audit writes are fire-and-forget from the caller's perspective — a failed audit write must never roll back the primary operation.
Import endpoint: POST /api/v1/quotes/import #45 (import endpoint) — each import run generates a batch UUID; all rows in the batch share the same ActorId; a single imported entry is written per record
Each entity detail page (quote, character, source, person) gains a collapsible History tab:
Entries sorted by OccurredAt desc
Shows: timestamp, actor (user display name or provider name), action, field, old → new value
completed and verified_absent entries are visually distinguished (e.g. a checkmark badge) — these are sign-off events, not just edits
API
GET /api/v1/quotes/{id}/history (and equivalents for other entity types) — returns the audit log for a single record. Paginated. No auth required for read; write endpoints require auth as usual.
Notes
Audit log is append-only — no UPDATE or DELETE on AuditLog rows
OldValue / NewValue are stored as strings; complex fields (e.g. genres array) are serialised as JSON
Add a reusable audit log that tracks who changed what, when, and via which source — across quotes, characters, sources, and persons — without adding columns to those core tables.
Why a separate table
The core tables (Quotes, Characters, Sources, Persons) must stay simple. Audit data grows without bound and has different access patterns (append-only, queried by entity, never updated). A single polymorphic
AuditLogtable handles all entity types uniformly and can be extended to new entity types with no schema changes.Table schema
Action vocabulary
createdupdatedenrichedcompletedisCompleteset totrue— signed off (#55)verified_absentnoValueKnown— confirmed no value exists (#55)importedActorId= import batch UUIDActor model
userenrichmenttmdb,wikidata, etc.)importsystemnullThe actor model is intentionally forward-compatible: it works before users exist (auth #15) by using
ActorType='system'orActorType='import'as the actor.Reusable service interface
Injected wherever writes happen: write endpoints (#16), import endpoint (#45), enrichment service (#19). All audit writes are fire-and-forget from the caller's perspective — a failed audit write must never roll back the primary operation.
Connections to existing issues
completedandverified_absentactions record who set those flags and whenIAuditLoggerafter filling each field;ActorType='enrichment',ActorId=provider nameActorId; a singleimportedentry is written per recordActorType='user'entries carry a real user UUID; before auth lands, system/import actors are sufficientBlazor UI: audit history panel
Each entity detail page (quote, character, source, person) gains a collapsible History tab:
OccurredAtdesccompletedandverified_absententries are visually distinguished (e.g. a checkmark badge) — these are sign-off events, not just editsAPI
GET /api/v1/quotes/{id}/history(and equivalents for other entity types) — returns the audit log for a single record. Paginated. No auth required for read; write endpoints require auth as usual.Notes
AuditLogrowsOldValue/NewValueare stored as strings; complex fields (e.g.genresarray) are serialised as JSON