a08bf51ced
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR 2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record "forandringar i bokforingssystemet som paverkar bokforingsposternas behandling samt nar dessa forandringar infordes", and BFN's commentary names behandlingsregler (automatkonteringar, fasta procentsatser) and new program versions as the examples. Until now both changed without a trace. Audit triggers on the behandlingsregler tables and the import logs: mapping_rules, booking_template_library, categorization_templates, salary_payroll_config, sie_imports, bank_file_imports. categorization_templates learns on every booking (occurrence_count, confidence, last_seen_date), so those telemetry-only updates are excluded by a WHEN clause the same way the api_keys request counters are (20260721115701): only real rule changes are logged. Measured against prod that is roughly 3 800 new audit rows a month against an audit_log already taking 371 688, so about +1 %. app_releases is an append-only log of program versions seen in production, written by the runtime the first time a build answers a request. Vercel exposes no build hook we can trust to write the row, so /api/version records it inside after(): the handler returns synchronously and a floating promise could be frozen before the insert lands, which is how a version log ends up silently empty. The service client is constructed lazily so the constantly polled public probe pays nothing once the module guard is set. Program versions are rolled up per Swedish calendar day in the report. main takes ~570 merges a month, so one event per version would be on the order of 7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the ~400 events a real company's year contains. The statutory unit is the date, and the same sentence qualifies the requirement to changes that affect processing, which a deploy list cannot distinguish anyway. app_releases keeps the per-version truth for anyone who needs to go deeper. AuditLogEntry.user_id becomes string | null. The column is nullable and write_audit_log() falls back to auth.uid(), which is NULL for a service-role or global write; the company-less salary_payroll_config rows are the first that routinely hit it, and the read model already coded for it. Also restores the point citations the 2026-07-27 pass removed while the chapter was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified against BFN's consolidated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): fix two fixture bugs in the behandlingshistorik trigger tests pg-real caught both, and neither is in the migration: the inserts fail before the trigger is reached. mapping_rules.rule_type is constrained to mcc_code / merchant_name / description_pattern / amount_threshold / combined; the test used 'merchant'. booking_template_library's btl_insert policy requires current_user_can_write() and company_id = current_active_company_id(), so the authenticated insert needs a company_members row and a user_preferences.active_company_id, the same setup booking-template-hidden.pg.test.ts uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): assert the booking-template audit row inside the user transaction withUserContext always rolls back, so the audit row the trigger writes is gone before an outside connection can see it. The trigger fires in the same transaction as the write, so the assertion belongs there too. The other cases in this file write on the pool (autocommit) and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * fix(reports): name every build id in the per-day program-version entry Raised by the compliance review on #2097: the roll-up listed five ids and a count, which leaves an auditor unable to reconstruct which versions ran that day. app_releases keeps the full record, but the report is the surface anyone actually reads. A day is bounded by the deploy rate (~19), so the full list stays one readable cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
/**
|
|
* Processing history (behandlingshistorik): append helper.
|
|
*
|
|
* Uses a service-role client internally (no INSERT RLS policy on
|
|
* processing_history: matching the event_log pattern). Company scoping
|
|
* is enforced by companyId in the event payload, not by RLS.
|
|
* Throws on failure.
|
|
*
|
|
* PII BOUNDARY: payload MUST contain pseudonymous IDs only (user UUIDs,
|
|
* company UUIDs, counterparty IDs). Never names, emails, personnummer,
|
|
* addresses, or phone numbers. These live in their source tables (profiles,
|
|
* customers, suppliers) and are referenced by ID. GDPR erasure pseudonymizes
|
|
* the source tables; processing_history events become undecipherable by
|
|
* reference, which is the required behavior per v0.2 §10.
|
|
*/
|
|
|
|
import type {
|
|
ProcessingHistoryAggregateType,
|
|
ProcessingHistoryActor,
|
|
} from '@/types'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { z } from 'zod'
|
|
|
|
/**
|
|
* Any service-role client with the query surface the append needs. Structural
|
|
* so both the Next-bound createServiceClient() and a script's own
|
|
* createClient(url, serviceRoleKey) satisfy it.
|
|
*/
|
|
type SupabaseClientLike = Pick<SupabaseClient, 'from'>
|
|
|
|
// ── Event type catalog ──────────────────────────────────────────
|
|
// Every event type the code emits, and the contract with the
|
|
// processing_event_types reference table: processing_history.event_type has an
|
|
// FK to it, and every append call site is best-effort try/catch, so a type
|
|
// that is missing from the table fails the insert silently and the act leaves
|
|
// no durable record at all. Ten types drifted out of the table exactly that
|
|
// way before this list existed.
|
|
//
|
|
// Adding an entry here therefore REQUIRES a migration registering the same
|
|
// string in public.processing_event_types, in the same change. The union
|
|
// makes an unregistered literal a compile error;
|
|
// tests/pg/processing-event-types.pg.test.ts makes an unregistered string a
|
|
// test failure. Keep it sorted.
|
|
|
|
export const PROCESSING_EVENT_TYPES = [
|
|
'AttachmentsTruncated',
|
|
'BankTransactionDuplicateDismissed',
|
|
'ChannelQuestionAnswered',
|
|
'ChannelQuestionAsked',
|
|
'ChannelQuestionExpired',
|
|
'DocumentDuplicateSkipped',
|
|
'DocumentExtractionAttempted',
|
|
'DocumentExtractionOverridden',
|
|
'DocumentExtractionRetried',
|
|
'DocumentIngested',
|
|
'InboxUnderlagReconciled',
|
|
'InvoiceDuplicatePaymentDismissed',
|
|
'InvoiceJournalEntrySkipped',
|
|
'OAuthClientRevoked',
|
|
'PendingOperationApproved',
|
|
'PendingOperationRejected',
|
|
'RateLimitedDropped',
|
|
'TransactionDocumentReplaced',
|
|
] as const
|
|
|
|
export type ProcessingHistoryEventType = (typeof PROCESSING_EVENT_TYPES)[number]
|
|
|
|
// ── PII validator ───────────────────────────────────────────────
|
|
// Rejects payloads containing Swedish personal identity numbers.
|
|
// Personnummer: YYMMDD-NNNN or YYMMDDNNNN (6+4 digits)
|
|
// Samordningsnummer: Same format but day +60
|
|
// Organisationsnummer: NNNNNN-NNNN (10 digits, but we catch the pattern)
|
|
|
|
// Word boundaries prevent false positives on Bankgiro (123456-7890) and
|
|
// invoice references like 202312-1234 that share the digit shape but aren't PII.
|
|
const PII_PATTERNS = [
|
|
/\b\d{6}-?\d{4}\b/, // personnummer, samordningsnummer
|
|
/\b\d{8}-?\d{4}\b/, // 12-digit variant (YYYYMMDD-NNNN) or orgnr
|
|
]
|
|
|
|
// UUIDs (RFC 4122, 8-4-4-4-12 hex layout) frequently contain all-digit segments
|
|
// that incorrectly match the 8+4 personnummer pattern: e.g. `57484518-3409-...`.
|
|
// Strip UUID-shaped substrings before PII matching so legitimate identifiers
|
|
// aren't rejected. Personnummer always sit outside the UUID shape, so this keeps
|
|
// the original safety intent intact.
|
|
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
|
|
|
function stringContainsPii(value: string): boolean {
|
|
const stripped = value.replace(UUID_PATTERN, '')
|
|
return PII_PATTERNS.some(pattern => pattern.test(stripped))
|
|
}
|
|
|
|
function containsPii(value: unknown): boolean {
|
|
if (typeof value === 'string') {
|
|
return stringContainsPii(value)
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some(containsPii)
|
|
}
|
|
if (value !== null && typeof value === 'object') {
|
|
return Object.values(value).some(containsPii)
|
|
}
|
|
return false
|
|
}
|
|
|
|
const piiSafePayload = z.record(z.string(), z.unknown()).refine(
|
|
(payload) => !containsPii(payload),
|
|
{ message: 'Payload contains PII (personnummer/samordningsnummer/orgnr pattern). Use pseudonymous IDs only.' }
|
|
)
|
|
|
|
function assertActorPiiSafe(actor: ProcessingHistoryActor): void {
|
|
if (actor.label && stringContainsPii(actor.label)) {
|
|
throw new Error(
|
|
'actor.label contains PII (personnummer/samordningsnummer/orgnr pattern). Use a pseudonymous descriptor only.'
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Input type ──────────────────────────────────────────────────
|
|
|
|
export interface AppendEventInput {
|
|
companyId: string
|
|
correlationId: string
|
|
causationId?: string
|
|
aggregateType: ProcessingHistoryAggregateType
|
|
aggregateId: string
|
|
eventType: ProcessingHistoryEventType
|
|
payload: Record<string, unknown>
|
|
payloadSchemaVersion?: number
|
|
actor: ProcessingHistoryActor
|
|
rubricVersion?: string
|
|
occurredAt: Date // mandatory: no default. Caller must set explicitly.
|
|
}
|
|
|
|
// ── Append functions ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Append a single event to processing_history.
|
|
*
|
|
* Uses a service-role client internally (bypasses RLS) since processing_history
|
|
* has no INSERT policy: matching the event_log pattern. Company scoping is
|
|
* enforced by the companyId in the event payload, not by RLS.
|
|
*
|
|
* Returns the generated event_id (pre-generated client-side for causation chaining).
|
|
*/
|
|
export async function appendProcessingHistory(
|
|
input: AppendEventInput
|
|
): Promise<string> {
|
|
return appendProcessingHistoryWithClient(createServiceClient(), input)
|
|
}
|
|
|
|
/**
|
|
* Same append, on a caller-supplied service-role client. For standalone
|
|
* scripts (e.g. scripts/backfill-inbox-booked-underlag.ts) that cannot build
|
|
* the Next-bound service client but must still write behandlingshistorik
|
|
* through the one shared row shape and PII validation (BFNAR 2013:2 p. 9.16:
|
|
* the change log has to reconcile across writers, so scripts never hand-roll
|
|
* the insert).
|
|
*/
|
|
export async function appendProcessingHistoryWithClient(
|
|
supabase: SupabaseClientLike,
|
|
input: AppendEventInput
|
|
): Promise<string> {
|
|
// Validate payload + actor.label contain no PII
|
|
piiSafePayload.parse(input.payload)
|
|
assertActorPiiSafe(input.actor)
|
|
|
|
const eventId = crypto.randomUUID()
|
|
|
|
const { error } = await supabase
|
|
.from('processing_history')
|
|
.insert({
|
|
event_id: eventId,
|
|
company_id: input.companyId,
|
|
correlation_id: input.correlationId,
|
|
causation_id: input.causationId ?? null,
|
|
aggregate_type: input.aggregateType,
|
|
aggregate_id: input.aggregateId,
|
|
event_type: input.eventType,
|
|
payload: input.payload,
|
|
payload_schema_version: input.payloadSchemaVersion ?? 1,
|
|
actor: input.actor,
|
|
rubric_version: input.rubricVersion ?? null,
|
|
occurred_at: input.occurredAt.toISOString(),
|
|
})
|
|
|
|
if (error) {
|
|
throw new Error(
|
|
`Failed to append processing_history event ${input.eventType}: ${error.message}`
|
|
)
|
|
}
|
|
|
|
return eventId
|
|
}
|
|
|
|
/**
|
|
* Append multiple events atomically (single INSERT).
|
|
* Used for batch operations (e.g., migration commits, multi-event command handlers).
|
|
*
|
|
* Returns array of generated event_ids in input order.
|
|
*/
|
|
export async function appendProcessingHistoryBatch(
|
|
inputs: AppendEventInput[]
|
|
): Promise<string[]> {
|
|
if (inputs.length === 0) return []
|
|
|
|
const eventIds = inputs.map(() => crypto.randomUUID())
|
|
|
|
// Validate all payloads + actor labels before any DB write
|
|
for (const input of inputs) {
|
|
piiSafePayload.parse(input.payload)
|
|
assertActorPiiSafe(input.actor)
|
|
}
|
|
|
|
const rows = inputs.map((input, i) => ({
|
|
event_id: eventIds[i],
|
|
company_id: input.companyId,
|
|
correlation_id: input.correlationId,
|
|
causation_id: input.causationId ?? null,
|
|
aggregate_type: input.aggregateType,
|
|
aggregate_id: input.aggregateId,
|
|
event_type: input.eventType,
|
|
payload: input.payload,
|
|
payload_schema_version: input.payloadSchemaVersion ?? 1,
|
|
actor: input.actor,
|
|
rubric_version: input.rubricVersion ?? null,
|
|
occurred_at: input.occurredAt.toISOString(),
|
|
}))
|
|
|
|
const supabase = createServiceClient()
|
|
|
|
const { error } = await supabase
|
|
.from('processing_history')
|
|
.insert(rows)
|
|
|
|
if (error) {
|
|
throw new Error(
|
|
`Failed to append processing_history batch (${inputs.length} events): ${error.message}`
|
|
)
|
|
}
|
|
|
|
return eventIds
|
|
}
|