The portal records every consequential action — role changes, access approvals, deal registrations, MDF decisions, uploads, team invites — in an append-only, long-retention activity_log table. The design goal is a detailed, per-org trail that never blocks the action it records, yet commits atomically with the security-critical transitions (so the log can never disagree with what actually happened).
Source: the table in apps/partners/lib/db/schema.ts, the helper apps/partners/lib/audit.ts, and the viewer app/(admin)/admin/activity/page.tsx.
activity_log#activityLog = pgTable("activity_log", {
id: uuid().defaultRandom().primaryKey(),
clerkOrgId: text(), // null = system/global event
actorClerkUserId: text(), // null = system/automated
actorEmail: text(),
action: text().notNull(), // dotted verb, e.g. "account_type.changed"
targetType: text(), // "user" | "access_request" | "opportunity" | "mdf" | …
targetId: text(),
summary: text().notNull(), // human-readable one-liner
metadata: jsonb().notNull().default('{}'), // structured before/after, ids, amounts
ipAddress: text(),
userAgent: text(),
createdAt: timestamp().notNull().defaultNow(),
}, indexes: on(clerkOrgId), on(actorClerkUserId), on(action), on(createdAt), on(targetType,targetId));
Five indexes cover the viewer's filters (org, actor, action, time, target). Retention is indefinite — there is no purge job; the log is the system of record for who-did-what. action is a dotted verb so the viewer can prefix-match (account_type matches account_type.changed and account_type.auto_derived).
recordActivity()#export type AuditExecutor = Pick<ReturnType<typeof db>, "insert">;
export async function recordActivity(input: ActivityInput, executor?: AuditExecutor): Promise<void>;
One function, two modes:
executor) — the audit row is inserted inside the caller's transaction, so it commits with the action that caused it. If the transaction rolls back, so does the audit row. Failures propagate (by design — the whole transaction must revert together). This is what the RBAC writer and the access-request saga use.executor) — a standalone insert wrapped in try/catch; a failure is logged via logger and swallowed so auditing never breaks a primary flow. This is what the add-on event emitters (deal registered, MDF submitted, content uploaded, …) use.The split is deliberate: the security-critical transitions (account-type changes, access-request approve/reject) audit atomically — you can never have an approval without its audit row, or an audit row for an approval that didn't commit. Everything else audits best-effort, because logging a deal registration must never fail the registration.
| Action | Emitted by | Mode |
|---|---|---|
account_type.changed, account_type.auto_derived | setAccountType (RBAC) | atomic |
access_request.approved, access_request.rejected | the approval saga | atomic |
opportunity.registered, opportunity.status_changed, opportunity.note_added | Opportunities actions | best-effort |
mdf.submitted, mdf.status_changed, mdf.claimed | MDF actions | best-effort |
content.uploaded, agreement.uploaded | Enablement actions | best-effort |
team.invited | team action | best-effort |
metadata carries the structured detail — e.g. an account-type change records { email, from, to, reason }; an approval records { email, companyName, invitationId }. Avoid putting secrets/tokens in metadata; it lives in the same air-gapped Partners Neon DB as the rest of the PII.
/admin/activity#Gated by requireInternalReadSession() — the capability matrix grants audit.read to all internal tiers (internal_team_member, admin, super_admin), so the read-only staff tier can audit without being able to act. It's a global, append-only feed with:
ILIKE), action (prefix match), and a from/to date range (the end date is made inclusive of the whole day).partner_profiles; null org renders as "System".It appears in the admin nav as Activity Log (an internalOnly link, so every internal tier sees it).
clerkOrgId scopes events to an org; system/automated events leave actor/org null.recordActivity consumeractivity_log columns