1/** 2 * @factory/shared — the org-scoped AUDIT-LOG query builders (TASK-0133). 3 * 4 * Single source of truth for "which AuditLog rows belong to an org". TWO 5 * consumers must agree on the scope by construction (a divergence would mean 6 * the viewer shows rows the retention engine purges, or vice versa): 7 * 8 * 1. the API's org audit viewer/export (`apps/api/src/modules/org/audit.service.ts`) 9 * 2. the worker's retention purge (`apps/worker/src/processors/retention.ts`) 10 * + async CSV export (`audit.export` maintenance job) 11 * 12 * The builders return PLAIN Prisma-`where`-shaped objects (no Prisma import — 13 * `@factory/shared` must not depend on the API): 14 * 15 * Org rows = 16 * - rows in ANY of the org's workspaces (`workspaceId IN wsIds`), PLUS 17 * - ORG-LEVEL rows: `workspaceId IS NULL` and (metadata.orgId = orgId — 18 * the SSO/SCIM admin + SSO-login emitters) OR (resourceType 19 * "organization"|"org" and resourceId = orgId — org.created/org.updated 20 * + org-admin policy/member/retention emitters). 21 * 22 * Recorded decision: NO new `AuditLog` index — EXPLAIN on the live 12k-row 23 * table uses the existing `audit_logs_workspaceId_action_createdAt_idx` 24 * (index scan, no seq scan at the filter combinations the viewer allows). 25 */ 26 27/** 28 * The viewer's filter set. `| undefined` on every optional (the worker 29 * compiles with `exactOptionalPropertyTypes: true` and must be able to 30 * build these objects from parsed, possibly-absent values). 31 */ 32export interface AuditFilters { 33 actorId?: string | undefined; 34 actorType?: string | undefined; 35 /** Exact action match. */ 36 action?: string | undefined; 37 /** `action LIKE 'prefix%'` (e.g. `page.`). */ 38 actionPrefix?: string | undefined; 39 resourceType?: string | undefined; 40 workspaceId?: string | undefined; 41 from?: Date | undefined; 42 to?: Date | undefined; 43} 44 45/** The documented OR-branch org scope (see the header). */ 46export function auditOrgScopeWhere(orgId: string, wsIds: string[]): Record<string, unknown> { 47 return { 48 OR: [ 49 { workspaceId: { in: wsIds } }, 50 { 51 workspaceId: null, 52 OR: [ 53 // SSO/SCIM org-level rows carry the org only in `metadata.orgId` 54 // (resourceType is "user" there). Prisma's `JsonFilter` needs 55 // `{ path, equals }` — a bare `{ orgId }` object is rejected at 56 // runtime ("Invalid database query"). 57 { metadata: { path: ["orgId"], equals: orgId } }, 58 { resourceType: { in: ["organization", "org"] }, resourceId: orgId } 59 ] 60 } 61 ] 62 }; 63} 64 65/** The viewer's user-facing filters (empty input → empty clause). */ 66export function auditFilterWhere(f: AuditFilters): Record<string, unknown> { 67 return { 68 ...(f.actorId ? { actorId: f.actorId } : {}), 69 ...(f.actorType ? { actorType: f.actorType } : {}), 70 ...(f.action ? { action: f.action } : {}), 71 ...(f.actionPrefix ? { action: { startsWith: f.actionPrefix } } : {}), 72 ...(f.resourceType ? { resourceType: f.resourceType } : {}), 73 ...(f.workspaceId ? { workspaceId: f.workspaceId } : {}), 74 ...(f.from || f.to 75 ? { createdAt: { ...(f.from ? { gte: f.from } : {}), ...(f.to ? { lte: f.to } : {}) } } 76 : {}) 77 }; 78} 79 80/** Org scope ∧ user filters (the shape both consumers pass to Prisma). */ 81export function auditScopedWhere( 82 orgId: string, 83 wsIds: string[], 84 f: AuditFilters 85): Record<string, unknown> { 86 return { AND: [auditOrgScopeWhere(orgId, wsIds), auditFilterWhere(f)] }; 87} 88