diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 551e5762..c4b05af9 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -108,6 +108,13 @@ function RegisterPageContent() { description: 'Detta BankID ar redan kopplat till ett konto. Forsok logga in istallet.', variant: 'destructive', }) + } else if (json.error === 'account_exists') { + toast({ + title: 'Kontot finns redan', + description: 'Ett konto med den har e-postadressen finns redan. Logga in och koppla BankID i installningarna.', + variant: 'destructive', + }) + router.push('/login') } else { toast({ title: 'Registrering misslyckades', diff --git a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts index 36056e61..5fa63ccc 100644 --- a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts +++ b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts @@ -213,6 +213,14 @@ describe('POST /items/:id/convert', () => { expect(status).toBe(200) expect(body.data.registration_journal_entry_id).toBe('je-1') expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled() + + // The emitted supplier_invoice.confirmed payload must reflect the just-written + // registration_journal_entry_id so the core handler's payload-level guard + // short-circuits instead of double-posting. + const emitCalls = (ctx.emit as ReturnType).mock.calls + const confirmed = emitCalls.find((c) => c[0].type === 'supplier_invoice.confirmed') + expect(confirmed).toBeDefined() + expect(confirmed![0].payload.supplierInvoice.registration_journal_entry_id).toBe('je-1') }) }) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index ac7f32fe..b2c6a595 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -1349,6 +1349,7 @@ export const invoiceInboxExtension: Extension = { ) if (journalEntry) { registrationJournalEntryId = journalEntry.id + ;(invoice as SupplierInvoice).registration_journal_entry_id = journalEntry.id await ctx.supabase .from('supplier_invoices') .update({ registration_journal_entry_id: journalEntry.id }) diff --git a/extensions/general/tic/__tests__/bankid-complete.test.ts b/extensions/general/tic/__tests__/bankid-complete.test.ts new file mode 100644 index 00000000..3dda2d0f --- /dev/null +++ b/extensions/general/tic/__tests__/bankid-complete.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('../lib/bankid-client', () => ({ + startBankIdAuth: vi.fn(), + pollBankIdSession: vi.fn(), + collectBankIdResult: vi.fn(), + cancelBankIdSession: vi.fn(), + requestEnrichment: vi.fn().mockResolvedValue({ status: 'failed', completedTypes: [] }), + fetchEnrichmentData: vi.fn(), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn(), + createClient: vi.fn(), +})) + +import { collectBankIdResult } from '../lib/bankid-client' +import { createServiceClient } from '@/lib/supabase/server' +import { ticExtension } from '../index' + +const TEST_KEY = 'a'.repeat(64) + +function findCompleteHandler() { + const route = ticExtension.apiRoutes!.find( + (r) => r.method === 'POST' && r.path === '/bankid/complete' + ) + if (!route) throw new Error('POST /bankid/complete route not found in ticExtension.apiRoutes') + return route.handler +} + +function makeSession(overrides: Partial<{ status: string; user: unknown }> = {}) { + return { + sessionId: 'test-session', + status: 'complete', + user: { + personalNumber: '199001011234', + givenName: 'Anna', + surname: 'Andersson', + name: 'Anna Andersson', + }, + ...overrides, + } as unknown as Awaited> +} + +type QueuedResult = { data?: unknown; error?: unknown } + +function mockServiceClient(fromResults: QueuedResult[]) { + const queue = [...fromResults] + + const chain = (): unknown => { + const result = queue.shift() ?? { data: null, error: null } + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) + return () => chain2(result) + }, + } + return new Proxy({}, handler) + } + const chain2 = (result: QueuedResult): unknown => { + const handler: ProxyHandler = { + get(_t, prop) { + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) + return () => chain2(result) + }, + } + return new Proxy({}, handler) + } + + const admin = { + createUser: vi.fn().mockResolvedValue({ data: { user: { id: 'new-user-uuid' } }, error: null }), + updateUserById: vi.fn().mockResolvedValue({ data: {}, error: null }), + generateLink: vi.fn().mockResolvedValue({ + data: { properties: { hashed_token: 'magic-token-hash' } }, + error: null, + }), + getUserById: vi.fn().mockResolvedValue({ + data: { user: { id: 'existing-user', email: 'existing@example.com' } }, + }), + } + + const client = { + from: vi.fn().mockImplementation(() => chain()), + auth: { admin }, + } + + vi.mocked(createServiceClient).mockReturnValue(client as unknown as ReturnType) + + return { admin, client } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('POST /bankid/complete', () => { + describe('signup mode — account_exists regression (CWE-287)', () => { + it('returns 409 account_exists and performs NO side effects when email is already registered', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) + const { admin, client } = mockServiceClient([ + { data: null }, // bankid_identities pnr lookup → not linked + { data: { id: 'victim-user-uuid' } }, // profiles email lookup → EXISTS + ]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup', email: 'victim@example.com' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string; data?: unknown }>( + await findCompleteHandler()(req) + ) + + expect(status).toBe(409) + expect(body.error).toBe('account_exists') + expect(body.data).toBeUndefined() + + // Critical: none of the account-mutation or session-issuance calls ran. + expect(admin.createUser).not.toHaveBeenCalled() + expect(admin.updateUserById).not.toHaveBeenCalled() + expect(admin.generateLink).not.toHaveBeenCalled() + + // No insert into bankid_identities. Only two from() calls should have happened + // (the pnr lookup and the profile lookup), neither of which is an insert. + const fromCalls = vi.mocked(client.from).mock.calls + expect(fromCalls.map((c) => c[0])).toEqual(['bankid_identities', 'profiles']) + }) + }) + + describe('signup mode — happy path', () => { + it('creates a new user, marks bankid_linked, and returns the magic link tokenHash', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) + const { admin } = mockServiceClient([ + { data: null }, // pnr lookup → not linked + { data: null }, // email lookup → not taken + { error: null }, // bankid_identities insert OK + ]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' }, + }) + const { status, body } = await parseJsonResponse<{ + data?: { tokenHash?: string; type?: string; isNewUser?: boolean } + }>(await findCompleteHandler()(req)) + + expect(status).toBe(200) + expect(body.data?.tokenHash).toBe('magic-token-hash') + expect(body.data?.type).toBe('magiclink') + expect(body.data?.isNewUser).toBe(true) + + expect(admin.createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'fresh@example.com', email_confirm: true }) + ) + expect(admin.updateUserById).toHaveBeenCalledWith( + 'new-user-uuid', + expect.objectContaining({ app_metadata: { bankid_linked: true } }) + ) + }) + }) + + describe('signup mode — pnr already linked', () => { + it('returns 409 already_linked before email lookup', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) + const { admin, client } = mockServiceClient([ + { data: { user_id: 'some-other-user' } }, // pnr lookup → LINKED + ]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup', email: 'x@example.com' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await findCompleteHandler()(req) + ) + + expect(status).toBe(409) + expect(body.error).toBe('already_linked') + expect(admin.createUser).not.toHaveBeenCalled() + // Only the pnr lookup ran — no profiles query. + expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities']) + }) + }) + + describe('login mode', () => { + it('returns 404 no_account when the BankID pnr is not linked to any user', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue(makeSession()) + const { admin } = mockServiceClient([ + { data: null }, // pnr lookup → not linked + ]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'login' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await findCompleteHandler()(req) + ) + + expect(status).toBe(404) + expect(body.error).toBe('no_account') + expect(admin.generateLink).not.toHaveBeenCalled() + }) + }) + + describe('input validation', () => { + it('returns 400 session_invalid when BankID session is not complete', async () => { + vi.mocked(collectBankIdResult).mockResolvedValue( + makeSession({ status: 'pending', user: undefined }) + ) + mockServiceClient([]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup', email: 'x@example.com' }, + }) + const { status, body } = await parseJsonResponse<{ error?: string }>( + await findCompleteHandler()(req) + ) + + expect(status).toBe(400) + expect(body.error).toBe('session_invalid') + }) + + it('returns 400 when email is missing in signup mode', async () => { + mockServiceClient([]) + + const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', { + method: 'POST', + body: { sessionId: 'test-session', mode: 'signup' }, + }) + const { status } = await parseJsonResponse(await findCompleteHandler()(req)) + + expect(status).toBe(400) + // collectBankIdResult should never be called — validation happens first. + expect(collectBankIdResult).not.toHaveBeenCalled() + }) + }) +}) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 5ff996cd..d23110ea 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -690,51 +690,53 @@ export const ticExtension: Extension = { ) } - // Check if email is already taken by a non-BankID user + // If the email is already registered, refuse signup. Linking BankID to an + // existing account must go through the authenticated /bankid/link route so + // email ownership is proven by password login first. (CWE-287) const { data: existingByEmail } = await supabase .from('profiles') .select('id') .eq('email', trimmedEmail!) .single() - let userId: string - let isNewUser = true - if (existingByEmail) { - // Email already exists — link BankID to existing account - userId = existingByEmail.id - isNewUser = false - - await supabase.auth.admin.updateUserById(userId, { - app_metadata: { bankid_linked: true }, - user_metadata: { full_name: name }, - }) - } else { - // Create new Supabase user - const randomPassword = crypto.randomBytes(32).toString('base64url') - const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ - email: trimmedEmail!, - email_confirm: true, - password: randomPassword, - user_metadata: { full_name: name }, - }) - - if (createError || !newUser?.user) { - log.error('createUser failed', { email: trimmedEmail, status: createError?.status, code: createError?.code, message: createError?.message }) - return NextResponse.json( - { error: 'Failed to create account', message: createError?.message }, - { status: 500 } - ) - } - - userId = newUser.user.id - - // Mark user as BankID-linked (skips TOTP MFA) - await supabase.auth.admin.updateUserById(userId, { - app_metadata: { bankid_linked: true }, + log.warn('bankid signup rejected — email already registered', { + sessionId, + pnrHashPrefix: pnrHash.slice(0, 8), }) + return NextResponse.json( + { + error: 'account_exists', + message: 'An account with this email already exists. Log in and link BankID from settings.', + }, + { status: 409 } + ) } + // Create new Supabase user + const randomPassword = crypto.randomBytes(32).toString('base64url') + const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ + email: trimmedEmail!, + email_confirm: true, + password: randomPassword, + user_metadata: { full_name: name }, + }) + + if (createError || !newUser?.user) { + log.error('createUser failed', { email: trimmedEmail, status: createError?.status, code: createError?.code, message: createError?.message }) + return NextResponse.json( + { error: 'Failed to create account', message: createError?.message }, + { status: 500 } + ) + } + + const userId = newUser.user.id + + // Mark user as BankID-linked (skips TOTP MFA) + await supabase.auth.admin.updateUserById(userId, { + app_metadata: { bankid_linked: true }, + }) + // Store BankID identity const { error: insertError } = await supabase .from('bankid_identities') @@ -775,7 +777,7 @@ export const ticExtension: Extension = { data: { tokenHash: link.properties.hashed_token, type: 'magiclink', - isNewUser, + isNewUser: true, }, }) } catch (error) { diff --git a/extensions/general/tic/lib/bankid-types.ts b/extensions/general/tic/lib/bankid-types.ts index 6f1a61ff..4536f240 100644 --- a/extensions/general/tic/lib/bankid-types.ts +++ b/extensions/general/tic/lib/bankid-types.ts @@ -153,7 +153,7 @@ export interface BankIdCompleteResponse { } export interface BankIdCompleteErrorResponse { - error: 'no_account' | 'already_linked' | 'session_invalid' | 'session_expired' + error: 'no_account' | 'already_linked' | 'account_exists' | 'session_invalid' | 'session_expired' givenName?: string surname?: string } diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts index de2e272c..2b3cfea8 100644 --- a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts +++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts @@ -36,13 +36,15 @@ describe('Supplier Invoice Core Handler', () => { it('creates registration journal entry for accrual method', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ - // 1. company_settings + // 1. supplier_invoices (re-fetch guard) + { data: { registration_journal_entry_id: null }, error: null }, + // 2. company_settings { data: { accounting_method: 'accrual' }, error: null }, - // 2. supplier_invoice_items + // 3. supplier_invoice_items { data: [{ id: 'item-1', account_number: '6200', line_total: 1000, sort_order: 0 }], error: null }, - // 3. supplier (type) + // 4. supplier (type) { data: { supplier_type: 'swedish_business' }, error: null }, - // 4. Update invoice with journal entry id + // 5. Update invoice with journal entry id { data: null, error: null }, ]) mockCreateClient.mockResolvedValue(supabase as never) @@ -73,6 +75,8 @@ describe('Supplier Invoice Core Handler', () => { it('skips journal entry for cash method', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ + // supplier_invoices (re-fetch guard) + { data: { registration_journal_entry_id: null }, error: null }, // company_settings with cash method { data: { accounting_method: 'cash' }, error: null }, ]) @@ -96,6 +100,7 @@ describe('Supplier Invoice Core Handler', () => { it('handles journal entry creation failure gracefully', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ + { data: { registration_journal_entry_id: null }, error: null }, { data: { accounting_method: 'accrual' }, error: null }, { data: [{ id: 'item-1', account_number: '6200', line_total: 500, sort_order: 0 }], error: null }, { data: { supplier_type: 'swedish_business' }, error: null }, @@ -126,4 +131,51 @@ describe('Supplier Invoice Core Handler', () => { consoleSpy.mockRestore() }) + + it('skips creation when payload.supplierInvoice.registration_journal_entry_id is already set', async () => { + const { supabase } = createQueuedMockSupabase() + mockCreateClient.mockResolvedValue(supabase as never) + + const invoice = makeSupplierInvoice({ + id: 'si-4', + registration_journal_entry_id: 'je-existing', + }) + + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { id: 'inbox-4' } as never, + supplierInvoice: invoice, + userId: 'user-1', + companyId: 'company-1', + }, + }) + + expect(mockCreateEntry).not.toHaveBeenCalled() + // No DB calls should have been made either (payload guard is pre-DB) + expect(mockCreateClient).not.toHaveBeenCalled() + }) + + it('skips creation when db row already has registration_journal_entry_id (stale payload)', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + // supplier_invoices re-fetch returns an already-linked entry + { data: { registration_journal_entry_id: 'je-db' }, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const invoice = makeSupplierInvoice({ id: 'si-5', registration_journal_entry_id: null }) + + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { id: 'inbox-5' } as never, + supplierInvoice: invoice, + userId: 'user-1', + companyId: 'company-1', + }, + }) + + expect(mockCreateEntry).not.toHaveBeenCalled() + }) }) diff --git a/lib/bookkeeping/handlers/supplier-invoice-handler.ts b/lib/bookkeeping/handlers/supplier-invoice-handler.ts index 85432168..17ed1596 100644 --- a/lib/bookkeeping/handlers/supplier-invoice-handler.ts +++ b/lib/bookkeeping/handlers/supplier-invoice-handler.ts @@ -19,13 +19,27 @@ async function handleSupplierInvoiceConfirmed( ): Promise { const { supplierInvoice, userId, companyId } = payload + // Guard: the inbox convert flow (and app/api/supplier-invoices) creates the + // registration entry inline before emitting. Without this short-circuit we + // double-post to 2440/2641/expense and overwrite registration_journal_entry_id. + if (supplierInvoice.registration_journal_entry_id) return + const supabase = await createClient() - // Check accounting method + // Re-fetch to catch callers whose in-memory payload is stale (invoice-inbox + // updates the row after insert but emits the pre-update object). + const { data: current } = await supabase + .from('supplier_invoices') + .select('registration_journal_entry_id') + .eq('id', supplierInvoice.id) + .single() + + if (current?.registration_journal_entry_id) return + const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('company_id', userId) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' diff --git a/lib/events/handlers/__tests__/event-log-handler.test.ts b/lib/events/handlers/__tests__/event-log-handler.test.ts index 9f150864..a576e9eb 100644 --- a/lib/events/handlers/__tests__/event-log-handler.test.ts +++ b/lib/events/handlers/__tests__/event-log-handler.test.ts @@ -41,6 +41,7 @@ describe('event-log-handler', () => { expect(mockInsert).toHaveBeenCalledWith( expect.objectContaining({ user_id: 'user-1', + company_id: 'company-1', event_type: 'invoice.created', entity_id: 'inv-123', }) @@ -64,6 +65,7 @@ describe('event-log-handler', () => { expect(mockInsert).toHaveBeenCalledWith( expect.objectContaining({ user_id: 'user-1', + company_id: 'company-1', event_type: 'customer.created', entity_id: 'cust-456', }) @@ -83,9 +85,9 @@ describe('event-log-handler', () => { expect(mockInsert).toHaveBeenCalledTimes(1) const rows = mockInsert.mock.calls[0][0] expect(rows).toHaveLength(3) - expect(rows[0]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-1' }) - expect(rows[1]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-2' }) - expect(rows[2]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-3' }) + expect(rows[0]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-1', company_id: 'company-1' }) + expect(rows[1]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-2', company_id: 'company-1' }) + expect(rows[2]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-3', company_id: 'company-1' }) }) it('does NOT persist journal_entry.drafted (excluded noise event)', async () => { @@ -148,7 +150,27 @@ describe('event-log-handler', () => { expect.objectContaining({ event_type: 'period.locked', entity_id: 'period-1', + company_id: 'company-1', }) ) }) + + it('skips insert when companyId is missing from payload', async () => { + await eventBus.emit({ + type: 'customer.created', + // deliberate bypass of TS types to simulate a future caller forgetting companyId + payload: { customer: makeCustomer(), userId: 'user-1' } as never, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('skips batch insert when companyId is missing from payload', async () => { + await eventBus.emit({ + type: 'transaction.synced', + payload: { transactions: [makeTransaction({ id: 'tx-1' })], userId: 'user-1' } as never, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) }) diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index 0bda32f5..243cd4af 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -81,10 +81,11 @@ function extractEntityId(payload: Record): string | null { } /** - * Strip userId from payload (stored in its own column) and return clean data. + * Strip userId and companyId from payload (each stored in its own column) and + * return clean data for the JSONB blob. */ -function stripUserId(payload: Record): Record { - const { userId: _userId, ...data } = payload +function stripMetaFields(payload: Record): Record { + const { userId: _userId, companyId: _companyId, ...data } = payload return data } @@ -94,6 +95,7 @@ function stripUserId(payload: Record): Record async function persistEvent( eventType: string, userId: string, + companyId: string, entityId: string | null, data: Record ): Promise { @@ -103,6 +105,7 @@ async function persistEvent( .from('event_log') .insert({ user_id: userId, + company_id: companyId, event_type: eventType, entity_id: entityId, data, @@ -124,6 +127,15 @@ export function registerEventLogHandler(): (() => void)[] { try { const rawPayload = payload as Record const userId = rawPayload.userId as string + const companyId = rawPayload.companyId + + // All persisted event payloads mandate companyId in TypeScript; this guard + // protects against any caller that bypasses the type system. Writing NULL + // would make the row invisible to the company_id-scoped SELECT RLS policy. + if (typeof companyId !== 'string' || companyId.length === 0) { + log.error(`Event ${eventType} missing companyId; skipping persistence`) + return + } // transaction.synced carries an array — batch insert if (eventType === 'transaction.synced' && Array.isArray(rawPayload.transactions)) { @@ -132,6 +144,7 @@ export function registerEventLogHandler(): (() => void)[] { const rows = transactions.map(tx => ({ user_id: userId, + company_id: companyId, event_type: eventType, entity_id: typeof tx.id === 'string' ? tx.id : null, data: { transaction: tx }, @@ -146,8 +159,8 @@ export function registerEventLogHandler(): (() => void)[] { } const entityId = extractEntityId(rawPayload) - const data = stripUserId(rawPayload) - await persistEvent(eventType, userId, entityId, data) + const data = stripMetaFields(rawPayload) + await persistEvent(eventType, userId, companyId, entityId, data) } catch (err) { log.error(`Event log handler error for ${eventType}:`, err) }