Skip to content

Deepen the Audit context: one recordAuditLog() seam instead of 17 scattered inserts #110

Description

@f-amine

Filed by the autonomous architecture cycle (/improve-codebase-architecture). Top deepening opportunity this pass.

Problem

CONTEXT.md invariant: "AuditLog rows are written from server-side action handlers; never directly by clients" and "append-only / immutable once written". Today there is no seam enforcing that — every writer hand-rolls the insert.

17 insert(auditLog) call sites across 10 files, all the same boilerplate:

await db
  .insert(auditLog)
  .values({
    id: randomUUID(),
    actorUserId: opts.userId,
    action: "affiliate.enrolled",
    targetType: "affiliate",
    targetId: id,
    metadata: { code },
  })
  .onConflictDoNothing();

Each caller must import randomUUID, the auditLog schema, and db just to record one event. Shape drift is already visible (some sites set organizationId/ipAddress/userAgent, some don't; conflict handling is copy-pasted). Any change to the write contract — id strategy, default createdAt normalization, conflict policy, redaction of metadata, enforcing the immutability invariant — must be patched in 17 places.

Call sites

  • packages/api/src/affiliate.ts
  • packages/api/src/gdpr.ts
  • packages/api/src/referral.ts
  • packages/auth/src/lib/welcome.ts
  • apps/web/src/app/onboarding/_actions.ts
  • apps/web/src/app/api/webhooks/polar/route.ts
  • apps/web/src/app/api/webhooks/resend/route.ts
  • apps/admin/src/app/[locale]/users/_actions.ts
  • apps/admin/src/app/[locale]/feature-flags/_actions.ts
  • (packages/db/scripts/seed.ts — leave as-is, seed is not an action handler)

Deletion test

Passes. Deleting today's per-site code does not move the complexity elsewhere — it concentrates it. A single recordAuditLog(...) makes the Audit context deep: a one-line interface (action, targetType, targetId, optional actorUserId/organizationId/metadata/request ctx) over the id-gen + insert + conflict + invariant behind it.

Proposed seam

New module in the Audit bounded context — colocated with the schema it owns:

// packages/db/src/audit.ts  (or packages/api/src/lib/audit.ts)
export async function recordAuditLog(event: {
  action: string
  targetType?: string
  targetId?: string
  actorUserId?: string | null
  organizationId?: string | null
  metadata?: Record<string, unknown>
  request?: { ipAddress?: string; userAgent?: string }
}): Promise<void>

Owns: randomUUID() id, onConflictDoNothing(), future immutability/redaction policy. All 9 handler files call it; the schema + db imports disappear from callers.

Acceptance criteria

  • recordAuditLog() lives in one module in the Audit context; exported for api + apps + auth.
  • All 9 action-handler call sites use it; no insert(auditLog) remains outside the seam (seed script exempt).
  • Unit test against the seam covers: id generated, conflict tolerated, optional fields omitted-vs-present. The interface is the test surface — current code has no audit-write test.
  • CONTEXT.md Audit entry references the seam as the only sanctioned write path.
  • pnpm typecheck && pnpm test && pnpm lint green.

Size / risk

Low risk, mechanical. ~10 files, ~17 sites. Vertical slice; one PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions