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:
co-authored by
Claude Fable 5
parent
f929b4b1d2
commit
1fa34aa7ca
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 > 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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, '&')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user