* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface The company_members -> profiles!inner(email) PostgREST embed has no FK to traverse (company_members.user_id references auth.users), so it 400'd and silently killed all four notification emails since they shipped. Recipient lookup is now a shared two-step helper (lib/notifications/member-email): kvittens confirmations, skattekonto drift alerts (tax-contact routing preserved via the plural variant) and backup alerts deliver again. The connection-expired email is deleted instead of fixed: with SKV's 65-minute personal sessions it was one mail per connect (see DECISIONS.md); the event and needs_reconsent flagging stay. For MCP-first users the agent is the notification surface, so: - SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is normal (~1h by SKV design), only a person can reconnect with BankID, do not retry until they confirm. Inline strings (declaration-status, read routes, v1 pitfalls, accounted-api skill) aligned. - gnubok_get_agent_briefing gains an optional skatteverket_connection block (status/source/connected_at + directive message on needs_reconsent), emitted only when a connection or verified system grant exists, so agents warn the user at session start instead of failing mid-task. Payload bench ceiling bumped 59.95K -> 60.15K for the outputSchema contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): drift email resolves recipients via service client; review fixes The skeptic pass refuted the drift-email repair: skattekonto.drift_detected is emitted only by the nightly cron, and the extension registry builds each event handler a fresh ctx from the anonymous cookie client (or none at all on cookieless requests), so RLS returned zero company_members rows and the two-step lookup still resolved no recipient. The handler now builds its own service-role client, the same documented pattern as the retired connection-expired handler; drift tests exercise the handler without ctx, matching the cron reality. CodeRabbit findings: resolveMemberEmails pages both queries through fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000 rows); the v1 vat-declarations pitfall and regenerated accounted-api docs now name both auth paths (member BankID connection or verified ombud grant); the briefing's system-before-user priority carries a cross-reference to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc states the service-role-client requirement (profiles RLS is own-row-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
211 lines
7.6 KiB
TypeScript
211 lines
7.6 KiB
TypeScript
/**
|
|
* Kvittens confirmation email: the atomic claim-then-send dedup.
|
|
*
|
|
* The notification_log row is inserted FIRST as a claim (guarded by the
|
|
* partial unique index from migration 20260712113000); the email is only
|
|
* sent when this invocation won the insert. A 23505 unique violation means
|
|
* another overlapping cron run already claimed the kvittens: no send.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const mockIsConfigured = vi.fn()
|
|
const mockSendEmail = vi.fn()
|
|
vi.mock('@/lib/email/service', () => ({
|
|
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
|
|
}))
|
|
|
|
import { sendKvittensNotification } from '../lib/kvittens-notification'
|
|
|
|
interface RecordedOp {
|
|
table: string
|
|
op: 'select' | 'insert' | 'delete'
|
|
payload?: Record<string, unknown>
|
|
filters: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
|
|
* assertions need the recorded operation ORDER and payloads: claim insert
|
|
* before send, delete filters on release.
|
|
*/
|
|
function makeSupabase(opts: {
|
|
alreadyRow?: { id: string } | null
|
|
member?: Record<string, unknown> | null
|
|
insertError?: { code?: string; message: string } | null
|
|
} = {}) {
|
|
const ops: RecordedOp[] = []
|
|
const from = (table: string) => {
|
|
const call: RecordedOp = { table, op: 'select', filters: {} }
|
|
ops.push(call)
|
|
const builder: Record<string, unknown> = {}
|
|
Object.assign(builder, {
|
|
select: () => builder,
|
|
insert: (payload: Record<string, unknown>) => {
|
|
call.op = 'insert'
|
|
call.payload = payload
|
|
return Promise.resolve({ data: null, error: opts.insertError ?? null })
|
|
},
|
|
delete: () => {
|
|
call.op = 'delete'
|
|
return builder
|
|
},
|
|
eq: (key: string, value: unknown) => {
|
|
call.filters[key] = value
|
|
return builder
|
|
},
|
|
maybeSingle: async () => {
|
|
if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null }
|
|
// Two-step recipient lookup (lib/notifications/member-email): the
|
|
// membership check first, then the profile email.
|
|
if (table === 'company_members') {
|
|
const member = opts.member === undefined ? { user_id: 'user-1' } : opts.member
|
|
return { data: member, error: null }
|
|
}
|
|
if (table === 'profiles') {
|
|
return { data: { email: 'user@example.com' }, error: null }
|
|
}
|
|
return { data: null, error: null }
|
|
},
|
|
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
|
|
})
|
|
return builder
|
|
}
|
|
return { supabase: { from } as unknown as SupabaseClient, ops }
|
|
}
|
|
|
|
const baseInput = {
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
kind: 'agi' as const,
|
|
period: '202606',
|
|
kvittensnummer: 'KV-123',
|
|
referenceId: '3f9d3c9a-1c2b-4d5e-8f6a-7b8c9d0e1f2a',
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockIsConfigured.mockReturnValue(true)
|
|
mockSendEmail.mockResolvedValue({ success: true })
|
|
})
|
|
|
|
describe('sendKvittensNotification', () => {
|
|
it('claims the notification_log row BEFORE sending the email', async () => {
|
|
const { supabase, ops } = makeSupabase()
|
|
let claimsAtSendTime = -1
|
|
mockSendEmail.mockImplementation(async () => {
|
|
claimsAtSendTime = ops.filter((o) => o.op === 'insert').length
|
|
return { success: true }
|
|
})
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: true })
|
|
expect(claimsAtSendTime).toBe(1)
|
|
const insert = ops.find((o) => o.op === 'insert')
|
|
expect(insert?.table).toBe('notification_log')
|
|
expect(insert?.payload).toMatchObject({
|
|
user_id: 'user-1',
|
|
company_id: 'company-1',
|
|
notification_type: 'skv_kvittens',
|
|
reference_id: baseInput.referenceId,
|
|
delivery_status: 'sent',
|
|
})
|
|
})
|
|
|
|
it('a 23505 unique violation on the claim means already claimed: no email', async () => {
|
|
const { supabase } = makeSupabase({
|
|
insertError: { code: '23505', message: 'duplicate key value violates unique constraint' },
|
|
})
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'duplicate' })
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('a non-23505 claim failure fails closed: no email, reason claim_failed', async () => {
|
|
const { supabase } = makeSupabase({
|
|
insertError: { code: '57014', message: 'canceling statement' },
|
|
})
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'claim_failed' })
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('the pre-check fast path short-circuits on an existing row', async () => {
|
|
const { supabase, ops } = makeSupabase({ alreadyRow: { id: 'log-1' } })
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'duplicate' })
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
expect(ops.some((o) => o.op === 'insert')).toBe(false)
|
|
})
|
|
|
|
it('releases the claim when the send fails, so a later run can retry', async () => {
|
|
const { supabase, ops } = makeSupabase()
|
|
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'send_failed' })
|
|
const release = ops.find((o) => o.op === 'delete')
|
|
expect(release?.table).toBe('notification_log')
|
|
expect(release?.filters).toMatchObject({
|
|
user_id: 'user-1',
|
|
notification_type: 'skv_kvittens',
|
|
reference_id: baseInput.referenceId,
|
|
})
|
|
})
|
|
|
|
it('releases the claim when the send throws', async () => {
|
|
const { supabase, ops } = makeSupabase()
|
|
mockSendEmail.mockRejectedValue(new Error('network'))
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'error' })
|
|
expect(ops.some((o) => o.op === 'delete' && o.table === 'notification_log')).toBe(true)
|
|
})
|
|
|
|
it('maps a non-uuid reference id (VAT composite key) to a deterministic uuid', async () => {
|
|
// notification_log.reference_id is a uuid column; the VAT cron passes a
|
|
// composite string key. It must map to the SAME uuid on every run for
|
|
// the claim to dedup.
|
|
const input = { ...baseInput, kind: 'vat' as const, referenceId: 'vat_company-1_202606' }
|
|
|
|
const run1 = makeSupabase()
|
|
await sendKvittensNotification(run1.supabase, input)
|
|
const run2 = makeSupabase()
|
|
await sendKvittensNotification(run2.supabase, input)
|
|
|
|
const ref1 = run1.ops.find((o) => o.op === 'insert')?.payload?.reference_id as string
|
|
const ref2 = run2.ops.find((o) => o.op === 'insert')?.payload?.reference_id as string
|
|
expect(ref1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
|
expect(ref2).toBe(ref1)
|
|
})
|
|
|
|
it('does nothing when email is not configured', async () => {
|
|
mockIsConfigured.mockReturnValue(false)
|
|
const { supabase, ops } = makeSupabase()
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
|
|
expect(ops).toHaveLength(0)
|
|
})
|
|
|
|
it('does not claim or send when the token owner is no longer a member', async () => {
|
|
const { supabase, ops } = makeSupabase({ member: null })
|
|
|
|
const result = await sendKvittensNotification(supabase, baseInput)
|
|
|
|
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
|
|
expect(mockSendEmail).not.toHaveBeenCalled()
|
|
expect(ops.some((o) => o.op === 'insert')).toBe(false)
|
|
})
|
|
})
|