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