Code

Weave an event into every state change

Five files as tabs. The audit helper now threads through every state change, and the detail page grows the trail. documents.gosigning.gosigning_view.goweb/signing.godocuments.templ.

1. internal/handlers/documents.go (modify) — two handlers gain an event, inside a transaction:

  • UploadDocument now wraps CreateDocument and an evtUploaded event in one transaction (rolling back the blob if either fails).
  • DeleteDocument wraps DeleteDraftDocument and an evtDeleted event in one transaction — and this is the payoff: because document_id is FK-free, the delete removes the document while its audit rows (including "Deleted draft") stay.
  • renderDocumentDetail records an evtTamper event when integrity fails on view, loads the trail with ListAuditEventsByDocument, and passes it to the page.

2. internal/handlers/signing.go (modify) — the signing flow records the interesting moments: evtSent (in the invite transaction), evtViewed (best-effort, when a pending signer opens their link), evtSigned and — for the last signer — evtCompleted (both in the sign transaction), and evtBadToken (a repeat-sign or expired-link attempt). Each uses the actor that fits: actorUser, actorSigner, or actorSystem.

3. internal/handlers/signing_view.go (modify) — add viewAuditEvents, which maps db.AuditEvent rows into web.AuditView and flags the security-relevant types (bad_token, tamper_detected) for emphasis.

4. internal/web/signing.go (modify) — add the AuditView struct.

5. internal/web/documents.templ (modify) — DocumentDetail grows an events parameter and renders @AuditLog(events); AuditLog is a new component that shows each event's time, message, and actor, with security rows highlighted.

Verify — reseed, run, and exercise the whole flow while watching the audit trail on a document's page:

  • Upload a document, open it — the trail shows one event: Uploaded "contract.pdf" ..., actor you.
  • Send it to two signers — a Sent for signature to 2 signer(s) event, actor you.
  • Open a signing link — a ... opened the signing link event, actor signer. Sign — a ... signed as "..." event. Sign as the second — signed, then All invited signers have signed — document completed, actor system. Two events in one transaction, same timestamp, ordered by seq.
  • Tamper with the file (as last lesson) and reload — an Integrity check FAILED event, highlighted.

Now the property the whole table was designed for. Delete a draft and confirm its history outlives it:

$ psql "$DATABASE_URL" -c "select event_type, actor_label, message from audit_events
                           where document_id='<the deleted draft id>' order by seq;"
    event_type      | actor_label |            message
--------------------+-------------+--------------------------------
 document.uploaded  | you@ex.com  | Uploaded "notes.txt" (12 B, ...)
 document.deleted   | you@ex.com  | Deleted draft "notes.txt"

The documents row is gone; its audit rows remain, document_id still set. A foreign key would have cascaded them away or blocked the delete. FK-free is why the trail survives — the point of the whole lesson.