feat(skatteverket): repair notification recipients + make the agent the SKV notification surface (#1887)

* 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>
This commit is contained in:
Mattsson
2026-08-25 12:09:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f929b4b1d2
commit 1fa34aa7ca
21 changed files with 664 additions and 637 deletions
@@ -80,8 +80,8 @@ describe('shouldSendBackupAlert', () => {
})
/**
* Supabase stub: company_members lookup resolves a member with an email,
* company_settings resolves a company name.
* Supabase stub: the two-step recipient lookup (company_members membership
* check, then profiles email), and company_settings resolves a company name.
*/
function makeSupabase(options: { member?: unknown; companyName?: string | null } = {}) {
const from = vi.fn().mockImplementation((table: string) => {
@@ -91,14 +91,16 @@ function makeSupabase(options: { member?: unknown; companyName?: string | null }
maybeSingle: vi.fn().mockImplementation(() => {
if (table === 'company_members') {
return Promise.resolve({
data:
options.member !== undefined
? options.member
: { user_id: 'u-1', profiles: { email: 'emil@example.com' } },
data: options.member !== undefined ? options.member : { user_id: 'u-1' },
error: null,
})
}
if (table === 'profiles') {
return Promise.resolve({ data: { email: 'emil@example.com' }, error: null })
}
return Promise.resolve({
data: { company_name: options.companyName ?? 'Testbolag AB' },
error: null,
})
}),
}
@@ -14,6 +14,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
const log = createLogger('cloud-backup-alert')
@@ -114,29 +115,6 @@ export async function sendBackupFailureAlert(
}
}
/**
* The recipient must still be an active member of the company: a schedule
* owner who has since been removed must not receive alerts for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
async function fetchCompanyName(
supabase: SupabaseClient,
companyId: string
@@ -2,7 +2,7 @@
* Tests for gnubok_get_agent_briefing: session-bootstrap context for the
* specialized accountant agent over MCP.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { tools } from '../server'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from '../recommended-tools'
import { workflowSkills } from '../skills'
@@ -57,6 +57,8 @@ function mockSupabase(opts: {
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
dimensionRuleRows?: Array<{ account_number: string; rule_type: string; dimension_id: string }>
dimensionsEnabled?: boolean
// skatteverket_tokens rows for the connection-health block. Default: none.
skvTokenRows?: Array<{ user_id: string; status: string | null; created_at: string | null }>
errors?: { profile?: string; memory?: string; atoms?: string }
}) {
const profile = opts.profile === undefined ? null : opts.profile
@@ -171,6 +173,9 @@ function mockSupabase(opts: {
// PR10: the briefing surfaces active rules; default none.
return chainResolving(opts.dimensionRuleRows ?? [])
}
if (table === 'skatteverket_tokens') {
return chainResolving(opts.skvTokenRows ?? [])
}
throw new Error(`Unexpected table in test mock: ${table}`)
}),
}
@@ -455,6 +460,107 @@ describe('gnubok_get_agent_briefing tool', () => {
})
})
describe('skatteverket_connection health block', () => {
const tool = () => tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const originalEnabled = process.env.SKATTEVERKET_ENABLED
beforeEach(() => {
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (originalEnabled === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = originalEnabled
})
it('omits the block entirely for a company that never connected', async () => {
const supabase = mockSupabase({ profile: null, skvTokenRows: [] })
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect('skatteverket_connection' in result).toBe(false)
})
it('omits the block when the skatteverket extension is disabled, even with a token row', async () => {
process.env.SKATTEVERKET_ENABLED = 'false'
const supabase = mockSupabase({
profile: null,
skvTokenRows: [{ user_id: 'user-1', status: 'active', created_at: '2026-08-25T09:12:00Z' }],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect('skatteverket_connection' in result).toBe(false)
})
it('reports an active personal session with its connect time (the ~65 min fuse)', async () => {
const supabase = mockSupabase({
profile: null,
skvTokenRows: [{ user_id: 'user-1', status: 'active', created_at: '2026-08-25T09:12:00Z' }],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as {
skatteverket_connection?: {
status: string
source: string
connected_at?: string | null
message?: string
}
}
expect(result.skatteverket_connection).toEqual({
status: 'active',
source: 'user',
connected_at: '2026-08-25T09:12:00Z',
})
})
it('reports needs_reconsent with the directive message so the agent warns the user up front', async () => {
const supabase = mockSupabase({
profile: null,
skvTokenRows: [
{ user_id: 'user-1', status: 'needs_reconsent', created_at: '2026-08-25T08:00:00Z' },
],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as {
skatteverket_connection?: { status: string; source: string; message?: string }
}
expect(result.skatteverket_connection).toMatchObject({
status: 'needs_reconsent',
source: 'user',
})
expect(result.skatteverket_connection?.message).toContain('BankID')
expect(result.skatteverket_connection?.message).toContain('ca 1 timme')
})
it('declares the block in the outputSchema as optional (never required)', () => {
const output = tool().outputSchema as {
properties: Record<string, unknown>
required: string[]
}
expect(output.properties.skatteverket_connection).toBeDefined()
expect(output.required).not.toContain('skatteverket_connection')
})
})
describe('recommended_tools workflow loadouts (issue #1098)', () => {
it('every tool name in every loadout exists in the actual tool registry (drift guard)', () => {
const known = new Set(tools.map((t) => t.name))
@@ -203,9 +203,15 @@ describe('tools/list payload size guard', () => {
// silently, and agents inferred "consumed" from status 'failed' both
// ways. The enum is the contract; the description is one clause;
// headroom before the change was ~15 tokens, so even that crossed.
// * 60K to 60.2K with skatteverket_connection on the briefing: the
// connection-health block (status/source/connected_at) that lets an
// agent warn the user about a dead 65-minute SKV session at session
// start instead of mid-task. The runtime block is emitted only for
// companies with a connection; this cost is the outputSchema contract
// (~140 tokens), already trimmed to two short description strings.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(60_000)
expect(approxTokens).toBeLessThan(60_200)
})
})
+63
View File
@@ -260,6 +260,8 @@ import { readAgiSubmissionStatus } from '@/extensions/general/skatteverket/lib/a
import { buildMomsuppgift, resolveRedovisare } from '@/extensions/general/skatteverket/lib/declaration-prep'
import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/audit'
import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map'
import { findCompanyTokenUser, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
@@ -3812,6 +3814,22 @@ export const tools: McpTool[] = [
},
required: ['tool', 'when', 'include'],
},
skatteverket_connection: {
type: 'object',
additionalProperties: false,
description:
'Present only when a Skatteverket connection exists. needs_reconsent: only a person can fix it (BankID under Inställningar → Skatteverket); warn the user before starting SKV work.',
properties: {
status: { type: 'string', enum: ['active', 'needs_reconsent'] },
source: { type: 'string', enum: ['user', 'system'] },
connected_at: {
type: ['string', 'null'],
description: 'Personal sessions last ~65 min from this time; absent for system (ombud) connections.',
},
message: { type: 'string' },
},
required: ['status', 'source'],
},
},
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory', 'recommended_tools'],
},
@@ -3876,6 +3894,49 @@ export const tools: McpTool[] = [
}
})()
// Skatteverket connection health, so the agent warns the user at
// session start instead of discovering a dead session mid-task.
// Best-effort and emitted only when a connection (or verified system
// grant) exists: never-connected companies pay no payload for it.
// The system-before-user priority mirrors resolveReadAuth
// (skatteverket/lib/resolve-auth.ts); not reused directly because the
// briefing needs token metadata (createdAt, reconsent status) that
// resolveReadAuth deliberately collapses into an auth result.
const safeSkvConnection = (async (): Promise<
| {
status: 'active' | 'needs_reconsent'
source: 'user' | 'system'
connected_at?: string | null
message?: string
}
| null
> => {
try {
if (process.env.SKATTEVERKET_ENABLED !== 'true') return null
if (
getSystemAuthMode() === 'on' &&
isSystemAuthConfigured() &&
(await hasVerifiedGrant(companyId, 'lasombud'))
) {
return { status: 'active', source: 'system' }
}
const token = await findCompanyTokenUser(supabase, companyId)
if (!token) return null
if (token.needsReconsent) {
return {
status: 'needs_reconsent',
source: 'user',
connected_at: token.createdAt,
message:
'Skatteverket-sessionen har gått ut. Skatteverkets personliga inloggning gäller bara ca 1 timme, så detta är normalt. Be användaren ansluta igen med BankID under Inställningar → Skatteverket; bara en person kan göra det, så försök inte med Skatteverket-verktyg förrän användaren bekräftat.',
}
}
return { status: 'active', source: 'user', connected_at: token.createdAt }
} catch {
return null
}
})()
const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([
supabase
.from('agent_profiles')
@@ -4068,6 +4129,7 @@ export const tools: McpTool[] = [
}
const ledgerDigest = await safeLedgerDigest
const skvConnection = await safeSkvConnection
return {
company,
@@ -4083,6 +4145,7 @@ export const tools: McpTool[] = [
})),
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
...(ledgerDigest ? { ledger_context: ledgerDigest } : {}),
...(skvConnection ? { skatteverket_connection: skvConnection } : {}),
// Static per-workflow loadouts (issue #1098): lets a deferred-loading
// harness batch-load a whole workflow cluster in one call. Validated
// against the tool registry at module init (assertRecommendedLoadoutsValid).
@@ -1,230 +0,0 @@
/**
* Connection-expired email: the atomic claim-then-send dedup.
*
* One email per consent episode: the reference key is derived from
* (userId, token.created_at), claimed through notification_log under type
* 'skv_connection_expired' (partial unique index from migration
* 20260720090000). A re-observed expiry of the same token never notifies
* twice; a reconnect (new token row, new created_at) resets the episode.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createHash } from 'crypto'
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 { sendConnectionExpiredNotification } from '../lib/connection-expired-notification'
interface RecordedOp {
table: string
op: 'select' | 'insert' | 'delete'
payload?: Record<string, unknown>
filters: Record<string, unknown>
}
const TOKEN_CREATED_AT = '2026-06-30T13:09:50.729013+00:00'
function expectedReferenceUuid(userId: string, createdAt: string): string {
const hex = createHash('sha256')
.update(`skv-connection-expired|${userId}|${createdAt}`)
.digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/**
* 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: {
token?: { created_at: string } | null
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 === 'skatteverket_tokens') {
const token =
opts.token === undefined ? { created_at: TOKEN_CREATED_AT } : opts.token
return { data: token, error: null }
}
if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null }
if (table === 'company_members') {
const member =
opts.member === undefined
? { user_id: 'user-1', profiles: { email: 'user@example.com' } }
: opts.member
return { data: member, 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' }
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('sendConnectionExpiredNotification', () => {
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 sendConnectionExpiredNotification(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_connection_expired',
reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT),
delivery_status: 'sent',
})
})
it('sends the reconnect instructions to the token owner', async () => {
const { supabase } = makeSupabase()
await sendConnectionExpiredNotification(supabase, baseInput)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
const mail = mockSendEmail.mock.calls[0][0] as {
to: string
subject: string
text: string
}
expect(mail.to).toBe('user@example.com')
expect(mail.subject).toContain('Skatteverket')
expect(mail.text).toContain('BankID')
expect(mail.text).toContain('alla behörigheter')
expect(mail.text).toContain('/settings/tax')
})
it('skips via the fast path when the episode was already notified', async () => {
const { supabase } = makeSupabase({ alreadyRow: { id: 'log-1' } })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('treats a 23505 unique violation on the claim as a duplicate', async () => {
const { supabase } = makeSupabase({
insertError: { code: '23505', message: 'duplicate key value' },
})
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('skips the send when the claim insert fails for another reason', async () => {
const { supabase } = makeSupabase({
insertError: { code: '57014', message: 'canceling statement' },
})
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'claim_failed' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does nothing when the email service is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, ops } = makeSupabase()
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
expect(ops).toHaveLength(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('keys the episode on the (user, company) token row, not the user alone', async () => {
const { supabase, ops } = makeSupabase()
await sendConnectionExpiredNotification(supabase, baseInput)
// Rows are per (user, company): a multi-company operator holds several,
// and a user-only maybeSingle() errored out and silently skipped the
// email (#1673 follow-through).
const tokenRead = ops.find((o) => o.table === 'skatteverket_tokens')
expect(tokenRead?.filters).toMatchObject({ user_id: 'user-1', company_id: 'company-1' })
})
it('skips when no token row exists (already disconnected)', async () => {
const { supabase } = makeSupabase({ token: null })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'no_token' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('skips without claiming when the token owner is no longer a member', async () => {
const { supabase, ops } = makeSupabase({ member: null })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
expect(ops.find((o) => o.op === 'insert')).toBeUndefined()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('releases the claim when the email send reports failure', async () => {
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
const { supabase, ops } = makeSupabase()
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'send_failed' })
const del = ops.find((o) => o.op === 'delete')
expect(del?.table).toBe('notification_log')
expect(del?.filters).toMatchObject({
user_id: 'user-1',
notification_type: 'skv_connection_expired',
reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT),
})
})
})
@@ -56,13 +56,15 @@ function makeSupabase(opts: {
},
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', profiles: { email: 'user@example.com' } }
: opts.member
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 }),
@@ -256,15 +256,18 @@ describe('findCompanyTokenUser', () => {
expect(await findCompanyTokenUser(supabase, 'company-1')).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-a' })).toEqual({
userId: 'user-a',
needsReconsent: false,
createdAt: '1',
})
// Preferring a user whose row is dead still yields the live row.
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-c' })).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
})
})
@@ -9,7 +9,6 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
@@ -33,6 +32,17 @@ vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
// The handler builds its own SERVICE-ROLE client: the only emitter is the
// nightly cron, where the registry-built ctx is an anonymous (or absent)
// client that RLS would turn into "no members, no recipient".
const { serviceClientHolder } = vi.hoisted(() => ({
serviceClientHolder: { current: null as unknown },
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(() => serviceClientHolder.current),
}))
import { handleSkattekontoDriftDetected } from '../lib/skattekonto-drift-email'
interface QueryRecord {
@@ -41,26 +51,30 @@ interface QueryRecord {
filters: Record<string, unknown>
}
type MemberRow = { user_id: string; profiles: { email: string } }
type MemberRow = { user_id: string; email: string }
const OWNER: MemberRow = { user_id: 'user-1', profiles: { email: 'owner@example.com' } }
const ACCOUNTANT: MemberRow = { user_id: 'user-2', profiles: { email: 'revisor@byra.se' } }
const OWNER: MemberRow = { user_id: 'user-1', email: 'owner@example.com' }
const ACCOUNTANT: MemberRow = { user_id: 'user-2', email: 'revisor@byra.se' }
/**
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
* assertions need the selected COLUMN list per table: the bug this covers was
* a select of a column that does not exist on company_settings.
*
* Recipient resolution is the two-step lookup from
* lib/notifications/member-email: company_members yields user ids, profiles
* yields their emails via an .in() read.
*/
function makeSupabase(
opts: {
members?: MemberRow[] | null
membersError?: { message: string } | null
profilesError?: { message: string } | null
settings?: Record<string, unknown> | null
settingsError?: { message: string } | null
profile?: { email: string } | null
profileError?: { message: string } | null
} = {},
) {
const members = opts.members === undefined ? [OWNER] : opts.members ?? []
const queries: QueryRecord[] = []
const from = (table: string) => {
const record: QueryRecord = { table, filters: {} }
@@ -68,7 +82,7 @@ function makeSupabase(
const result = () => {
if (table === 'company_members') {
return {
data: opts.members === undefined ? [OWNER] : opts.members,
data: opts.membersError ? null : members.map((m) => ({ user_id: m.user_id })),
error: opts.membersError ?? null,
}
}
@@ -86,8 +100,10 @@ function makeSupabase(
}
if (table === 'profiles') {
return {
data: opts.profile === undefined ? { email: OWNER.profiles.email } : opts.profile,
error: opts.profileError ?? null,
data: opts.profilesError
? null
: members.map((m) => ({ id: m.user_id, email: m.email })),
error: opts.profilesError ?? null,
}
}
return { data: null, error: null }
@@ -102,7 +118,17 @@ function makeSupabase(
record.filters[key] = value
return builder
},
maybeSingle: async () => result(),
in: (key: string, value: unknown) => {
record.filters[key] = value
return builder
},
order: () => builder,
range: () => builder,
maybeSingle: async () => {
const r = result()
const rows = r.data as Array<Record<string, unknown>> | null
return { data: Array.isArray(rows) ? rows[0] ?? null : rows, error: r.error }
},
then: (resolve: (v: unknown) => void) => resolve(result()),
})
return builder
@@ -110,13 +136,9 @@ function makeSupabase(
return { supabase: { from } as unknown as SupabaseClient, queries }
}
function makeCtx(supabase: SupabaseClient): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
supabase,
} as unknown as ExtensionContext
/** Point the mocked createServiceClient at this test's supabase stub. */
function useServiceClient(supabase: SupabaseClient): void {
serviceClientHolder.current = supabase
}
const payload: EventPayload<'skattekonto.drift_detected'> = {
@@ -149,22 +171,24 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('sends to the configured tax contact when it belongs to an active member', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(sentTo()).toBe(ACCOUNTANT.profiles.email)
expect(sentTo()).toBe(ACCOUNTANT.email)
})
it('reads tax_contact_email: the column the settings UI actually writes', async () => {
const { supabase, queries } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const settingsQuery = queries.find((q) => q.table === 'company_settings')
// Exact match, not `toContain`: 'tax_contact_email' contains the phantom
@@ -179,7 +203,8 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settings: { tax_contact_email: 'Revisor@Byra.se' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe('Revisor@Byra.se')
})
@@ -187,9 +212,10 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('falls back to the syncing user when no tax contact is configured', async () => {
const { supabase } = makeSupabase({ members: [OWNER], settings: null })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.profiles.email)
expect(sentTo()).toBe(OWNER.email)
})
it('refuses a tax contact that is not an active member, and says so', async () => {
@@ -198,9 +224,10 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settings: { tax_contact_email: 'ex-admin@example.com' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.profiles.email)
expect(sentTo()).toBe(OWNER.email)
expect(warnedWith('not an active member')).toBe(true)
})
@@ -211,11 +238,12 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settingsError: { message: 'column company_settings.tax_contact_email does not exist' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read tax contact email')).toBe(true)
// Still delivers to the documented fallback rather than dropping the alert.
expect(sentTo()).toBe(OWNER.profiles.email)
expect(sentTo()).toBe(OWNER.email)
})
it('does not silently swallow a member lookup error', async () => {
@@ -224,30 +252,32 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
membersError: { message: 'permission denied for table company_members' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read company members')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not silently swallow a profile lookup error on the fallback path', async () => {
it('does not silently swallow a member-email lookup error', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
profile: null,
profileError: { message: 'timeout' },
profilesError: { message: 'timeout' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read syncing user profile')).toBe(true)
expect(warnedWith('could not read member emails')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('sends nothing when the company has no members at all', async () => {
const { supabase } = makeSupabase({ members: [] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(warnedWith('no authorised recipient')).toBe(true)
@@ -257,7 +287,8 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, queries } = makeSupabase({ members: [OWNER, ACCOUNTANT] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(queries).toHaveLength(0)
@@ -266,10 +297,11 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('keeps the drift figures out of the email body', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const body = `${mockSendEmail.mock.calls[0][0].text}${mockSendEmail.mock.calls[0][0].html}`
expect(body).not.toContain('1250')
+6 -6
View File
@@ -55,7 +55,6 @@ import {
SkattekontoBookingError,
} from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
@@ -196,7 +195,8 @@ function readAuthFailureResponse(reason: 'no_token' | 'needs_reconsent'): NextRe
if (reason === 'needs_reconsent') {
return NextResponse.json(
{
error: 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.',
error:
'Anslutningen mot Skatteverket har gått ut. Skatteverkets inloggning gäller bara ca 1 timme, så detta är normalt. Anslut igen med BankID.',
code: 'SESSION_EXPIRED',
},
{ status: 401 },
@@ -2483,15 +2483,15 @@ export const skatteverketExtension: Extension = {
},
],
// skattekonto.connection.expired is still emitted (needs_reconsent flagging,
// UI banner, agent briefing) but has no email consumer: with SKV's 65-minute
// personal sessions a per-episode expiry mail is one mail per connect, which
// trains users to ignore it. See DECISIONS.md 2026-08-25.
eventHandlers: [
{
eventType: 'skattekonto.drift_detected',
handler: handleSkattekontoDriftDetected,
},
{
eventType: 'skattekonto.connection.expired',
handler: handleSkattekontoConnectionExpired,
},
],
// Registry-resolved commit services for the MCP submit tools. The core
@@ -1,246 +0,0 @@
/**
* Connection-expired notification (email).
*
* Handler for `skattekonto.connection.expired`: emitted by syncSkattekonto
* when a terminal auth error (REFRESH_EXHAUSTED / SESSION_EXPIRED /
* TOKEN_CORRUPTED) kills the personal SKV token. Before this handler the
* event was emitted but unconsumed: the needs_reconsent state was only
* visible on the settings panel, which users have no reason to revisit, so
* a dead connection silently meant "skattekonto never syncs again". Prod
* accumulated ~70 companies in exactly that state.
*
* Email is the delivery channel (same rationale as kvittens-notification:
* push-notifications is a separate, optional extension and cross-extension
* imports are not allowed). The recipient is the token OWNER: only they can
* redo the BankID consent, so a company contact address would be actionable
* for the wrong person. The owner must still be an active member of the
* company.
*
* Deduplication: one email per consent episode. The reference key is derived
* from (userId, token.created_at): a reconnect creates a new token row with
* a new created_at, so a later expiry notifies again, while the nightly cron
* re-observing the same dead token stays silent. Claim-first through
* notification_log under type 'skv_connection_expired', made atomic by the
* partial unique index in migration 20260720090000 (mirrors the kvittens
* pattern).
*
* Best-effort by contract: a notification failure must never fail the sync
* (or cron) that observed the expiry. The body carries no financial data.
*/
import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EventPayload } from '@/lib/events/types'
import { createServiceClient } from '@/lib/supabase/server'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
const log = createLogger('skv-connection-expired-notification')
export async function handleSkattekontoConnectionExpired(
payload: EventPayload<'skattekonto.connection.expired'>,
): Promise<void> {
// Service-role client, NOT the registry-built ctx: the primary emitter is
// the nightly cron, whose request carries no user cookies, so the ctx the
// registry lazily builds there is an anonymous client that RLS turns into
// "no token, no member, nothing to do". Same rationale as the
// document-extraction handler.
const supabase = createServiceClient()
await sendConnectionExpiredNotification(supabase, {
companyId: payload.companyId,
userId: payload.userId,
})
}
export interface ConnectionExpiredInput {
companyId: string
/** The token-owning user: the only person who can redo the BankID consent. */
userId: string
}
export async function sendConnectionExpiredNotification(
supabase: SupabaseClient,
input: ConnectionExpiredInput,
): Promise<{ sent: boolean; reason?: string }> {
try {
const email = getEmailService()
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
// Episode key: the token row's created_at. No token row means nothing to
// reconnect (already disconnected): skip. Rows are per (user, company):
// without the company filter a multi-company operator's second row made
// maybeSingle() error out and the email was never sent.
const { data: token } = await supabase
.from('skatteverket_tokens')
.select('created_at')
.eq('user_id', input.userId)
.eq('company_id', input.companyId)
.maybeSingle()
const tokenCreatedAt = (token as { created_at?: string | null } | null)?.created_at
if (!tokenCreatedAt) return { sent: false, reason: 'no_token' }
const referenceUuid = toReferenceUuid(
`skv-connection-expired|${input.userId}|${tokenCreatedAt}`,
)
// Dedup fast path: cheap read that skips the work below on re-observed
// expiries. NOT the enforcement: the claim insert further down is.
const { data: already } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', input.userId)
.eq('notification_type', 'skv_connection_expired')
.eq('reference_id', referenceUuid)
.maybeSingle()
if (already) return { sent: false, reason: 'duplicate' }
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
if (!recipient) {
log.info('no authorised recipient for connection-expired email', {
companyId: input.companyId,
})
return { sent: false, reason: 'no_recipient' }
}
// Claim before sending: the partial unique index on notification_log
// (user_id, reference_id) where notification_type =
// 'skv_connection_expired' makes this atomic. Of two overlapping
// emitters exactly one wins the insert; the loser gets a unique
// violation and skips the send.
const { error: claimError } = await supabase.from('notification_log').insert({
user_id: input.userId,
company_id: input.companyId,
notification_type: 'skv_connection_expired',
reference_id: referenceUuid,
days_before: 0,
delivery_status: 'sent',
})
if (claimError) {
if (claimError.code === '23505') return { sent: false, reason: 'duplicate' }
// Without a claim we cannot guarantee single delivery: skip the send
// (fail closed on the never-twice guarantee) and let a later
// observation retry.
log.warn('connection-expired claim insert failed', {
companyId: input.companyId,
error: claimError.message,
})
return { sent: false, reason: 'claim_failed' }
}
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || 'https://gnubok.se').replace(/\/$/, '')
const settingsLink = `${appUrl}/settings/tax`
const subject = 'Anslutningen till Skatteverket behöver förnyas'
const text = [
'Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.',
'',
'Så här återansluter du:',
`1. Gå till Inställningar > Skatt i Accounted: ${settingsLink}`,
'2. Klicka på "Anslut igen" och legitimera dig med BankID.',
'3. Godkänn alla behörigheter på Skatteverkets samtyckessida.',
'',
'När du anslutit hämtas skattekontot direkt.',
].join('\n')
const html = [
'<p>Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.</p>',
'<p><strong>Så här återansluter du:</strong></p>',
'<ol>',
`<li>Gå till <a href="${escapeHtml(settingsLink)}">Inställningar &gt; Skatt i Accounted</a>.</li>`,
'<li>Klicka på "Anslut igen" och legitimera dig med BankID.</li>',
'<li>Godkänn alla behörigheter på Skatteverkets samtyckessida.</li>',
'</ol>',
'<p>När du anslutit hämtas skattekontot direkt.</p>',
].join('')
let result: Awaited<ReturnType<typeof email.sendEmail>>
try {
result = await email.sendEmail({ to: recipient, subject, text, html })
} catch (sendErr) {
// Release the claim so a later observation can retry the send.
await releaseClaim(supabase, input.userId, referenceUuid)
throw sendErr
}
if (!result.success) {
log.warn('connection-expired email send failed', {
companyId: input.companyId,
error: result.error,
})
await releaseClaim(supabase, input.userId, referenceUuid)
return { sent: false, reason: 'send_failed' }
}
return { sent: true }
} catch (err) {
log.warn('connection-expired notification failed', {
companyId: input.companyId,
error: err instanceof Error ? err.message : String(err),
})
return { sent: false, reason: 'error' }
}
}
/**
* notification_log.reference_id is a uuid column; the episode key is a
* composite string. Map it to a deterministic uuid-shaped SHA-256 digest so
* the same episode always resolves to the same claim row (same approach as
* kvittens-notification).
*/
function toReferenceUuid(referenceKey: string): string {
const hex = createHash('sha256').update(referenceKey).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/** Remove a claim whose email never went out, so a later run can retry. */
async function releaseClaim(
supabase: SupabaseClient,
userId: string,
referenceUuid: string,
): Promise<void> {
try {
await supabase
.from('notification_log')
.delete()
.eq('user_id', userId)
.eq('notification_type', 'skv_connection_expired')
.eq('reference_id', referenceUuid)
} catch (err) {
// A stuck claim only suppresses a retry of this one email: log and move on.
log.warn('failed to release connection-expired claim', {
userId,
referenceUuid,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* The recipient must still be an active member of the company (mirrors the
* kvittens rule): a token owner who has since been removed from the company
* must not receive connection nudges for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -91,8 +91,8 @@ export async function fetchVatDeclarationStatus(
http_status: 401,
error:
resolved.reason === 'needs_reconsent'
? 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.'
: 'Inte ansluten till Skatteverket.',
? 'Anslutningen mot Skatteverket behöver förnyas: Skatteverkets personliga inloggning gäller bara ca 1 timme, så detta är normalt. Be användaren ansluta igen med BankID under Inställningar → Skatteverket. Bara en person kan göra det; försök inte igen förrän användaren bekräftat.'
: 'Inte ansluten till Skatteverket. Be användaren ansluta med BankID under Inställningar → Skatteverket.',
}
}
@@ -26,6 +26,7 @@ import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
const log = createLogger('skv-kvittens-notification')
@@ -172,30 +173,6 @@ async function releaseClaim(
}
}
/**
* The recipient must still be an active member of the company (mirrors the
* drift-email rule): a token owner who has since been removed from the
* company must not receive filing confirmations for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
@@ -105,6 +105,12 @@ export async function resolveReadAuth(
export interface CompanyTokenUser {
userId: string
needsReconsent: boolean
/**
* When the serving row was issued (last consent or refresh; storeTokens is
* DELETE + INSERT). Personal SKV sessions live ~65 minutes from this time,
* so consumers (the agent briefing) can reason about likely expiry.
*/
createdAt: string | null
}
/**
@@ -141,8 +147,11 @@ export async function findCompanyTokenUser(
return null
}
const rows = ((data ?? []) as Array<{ user_id: string | null; status: string | null }>).filter(
(row): row is { user_id: string; status: string | null } => typeof row.user_id === 'string'
const rows = (
(data ?? []) as Array<{ user_id: string | null; status: string | null; created_at: string | null }>
).filter(
(row): row is { user_id: string; status: string | null; created_at: string | null } =>
typeof row.user_id === 'string'
)
if (rows.length === 0) return null
@@ -154,5 +163,6 @@ export async function findCompanyTokenUser(
return {
userId: pick.user_id,
needsReconsent: pick.status === 'needs_reconsent',
createdAt: pick.created_at ?? null,
}
}
@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmails } from '@/lib/notifications/member-email'
import { createServiceClient } from '@/lib/supabase/server'
import { formatDate } from '@/lib/utils'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
@@ -14,6 +17,12 @@ const log = createLogger('skattekonto-drift-email')
* (the dashboard SkattekontoDriftTile) so a misdelivered mail doesn't leak
* financial figures.
*
* Service-role client, NOT the registry-built ctx: the only emitter is the
* nightly cron, whose request carries no user cookies, so the ctx the
* registry lazily builds there is an anonymous client (or undefined) that
* RLS turns into "no members, no recipient, no mail". Same rationale as the
* document-extraction handler and the retired connection-expired handler.
*
* Recipient resolution is restricted to active members of the company. A
* stale company_settings.tax_contact_email that no longer corresponds to a
* member is never used. Falls back to the syncing user only if they're
@@ -24,15 +33,8 @@ const log = createLogger('skattekonto-drift-email')
*/
export async function handleSkattekontoDriftDetected(
payload: EventPayload<'skattekonto.drift_detected'>,
ctx?: ExtensionContext,
_ctx?: ExtensionContext,
): Promise<void> {
if (!ctx) {
log.warn('drift event fired without ctx: cannot resolve recipient', {
companyId: payload.companyId,
})
return
}
const email = getEmailService()
if (!email.isConfigured()) {
log.info('email service not configured: skipping drift alert', {
@@ -41,7 +43,8 @@ export async function handleSkattekontoDriftDetected(
return
}
const recipient = await resolveAuthorisedRecipient(ctx, payload.userId)
const supabase = createServiceClient()
const recipient = await resolveAuthorisedRecipient(supabase, payload.companyId, payload.userId)
if (!recipient) {
log.warn('no authorised recipient resolved for drift alert', {
companyId: payload.companyId,
@@ -120,39 +123,24 @@ export async function handleSkattekontoDriftDetected(
* whoever happened to trigger the sync.
*/
async function resolveAuthorisedRecipient(
ctx: ExtensionContext,
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<string | null> {
// 1. Build the set of active member emails for this company. We accept
// only addresses that appear here.
const { data: members, error: membersError } = await ctx.supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', ctx.companyId)
if (membersError) {
// Without the allowlist every candidate is rejected below, so a failed
// lookup silently cancels the alert. Say so out loud.
log.warn('could not read company members for drift alert', {
companyId: ctx.companyId,
error: membersError.message,
})
}
type MemberRow = { user_id: string; profiles: { email?: string | null } | { email?: string | null }[] | null }
// only addresses that appear here. A failed lookup resolves to an empty
// map, cancelling the alert: the helper logs it out loud.
const memberEmails = await resolveMemberEmails(supabase, companyId)
const allowedEmails = new Set<string>()
for (const m of (members ?? []) as MemberRow[]) {
const profile = Array.isArray(m.profiles) ? m.profiles[0] : m.profiles
if (profile?.email) allowedEmails.add(profile.email.toLowerCase())
}
for (const email of memberEmails.values()) allowedEmails.add(email.toLowerCase())
if (allowedEmails.size === 0) return null
// 2. Prefer the configured tax contact email IF it matches an active member.
const { data: settings, error: settingsError } = await ctx.supabase
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('tax_contact_email')
.eq('company_id', ctx.companyId)
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
@@ -160,7 +148,7 @@ async function resolveAuthorisedRecipient(
// trace is how a company silently stops getting alerts where it asked
// for them.
log.warn('could not read tax contact email: falling back to syncing user', {
companyId: ctx.companyId,
companyId,
error: settingsError.message,
})
}
@@ -171,24 +159,13 @@ async function resolveAuthorisedRecipient(
return contactEmail
}
log.warn('configured tax contact is not an active member: falling back to syncing user', {
companyId: ctx.companyId,
companyId,
})
}
// 3. Fall back to the syncing user's email if they're still a member.
const { data: profile, error: profileError } = await ctx.supabase
.from('profiles')
.select('email')
.eq('id', userId)
.maybeSingle()
if (profileError) {
log.warn('could not read syncing user profile for drift alert', {
companyId: ctx.companyId,
userId,
error: profileError.message,
})
}
const userEmail = (profile as { email?: string | null } | null)?.email
// 3. Fall back to the syncing user's email: present in the map only while
// they are still a member.
const userEmail = memberEmails.get(userId)
if (userEmail && allowedEmails.has(userEmail.toLowerCase())) {
return userEmail
}