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
|
||||
|
||||
Reference in New Issue
Block a user