* 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>
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
/**
|
|
* Failure-alert email for the cloud backup auto-sync.
|
|
*
|
|
* A backup that silently stops running is worse than no backup: the user
|
|
* believes they are covered. The cron calls this when a connection goes
|
|
* needs_reauth (retrying can never succeed) or when several consecutive
|
|
* auto-syncs have failed. Manual syncs never alert: the user is watching.
|
|
*
|
|
* Best-effort by design: an alert failure must never fail the sync loop.
|
|
* Throttling state (`last_alert_at`) lives on the schedule object and is
|
|
* persisted by the caller. The body carries no bookkeeping data, only the
|
|
* company name and the error summary.
|
|
*/
|
|
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')
|
|
|
|
/** At most one alert email per company per throttle window. */
|
|
export const ALERT_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000
|
|
/** Consecutive auto-sync failures before a repeated-failures alert fires. */
|
|
export const ALERT_FAILURE_THRESHOLD = 3
|
|
|
|
export type BackupAlertKind = 'needs_reauth' | 'repeated_failures'
|
|
|
|
export function shouldSendBackupAlert(params: {
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
lastAlertAt: string | null | undefined
|
|
now?: Date
|
|
}): boolean {
|
|
const now = params.now ?? new Date()
|
|
if (params.lastAlertAt) {
|
|
const last = new Date(params.lastAlertAt).getTime()
|
|
if (Number.isFinite(last) && now.getTime() - last < ALERT_THROTTLE_MS) {
|
|
return false
|
|
}
|
|
}
|
|
if (params.kind === 'needs_reauth') return true
|
|
return params.consecutiveFailures >= ALERT_FAILURE_THRESHOLD
|
|
}
|
|
|
|
export interface BackupAlertInput {
|
|
companyId: string
|
|
/** The user who configured the schedule: recipient candidate. */
|
|
userId: string
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
errorMessage: string | null
|
|
/** App origin used to build the reconnect link. */
|
|
origin: string
|
|
/**
|
|
* Destination that failed ("Google Drive", "Dropbox"). Named in the mail so
|
|
* a user backing up to both knows which one to reconnect. Defaults to Google
|
|
* Drive for callers written before the second provider existed.
|
|
*/
|
|
providerLabel?: string
|
|
}
|
|
|
|
export async function sendBackupFailureAlert(
|
|
supabase: SupabaseClient,
|
|
input: BackupAlertInput
|
|
): Promise<{ sent: boolean; reason?: string }> {
|
|
try {
|
|
const email = getEmailService()
|
|
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
|
|
|
|
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
|
|
if (!recipient) {
|
|
log.info('no authorised recipient for backup alert', { companyId: input.companyId })
|
|
return { sent: false, reason: 'no_recipient' }
|
|
}
|
|
|
|
const companyName = await fetchCompanyName(supabase, input.companyId)
|
|
const link = `${input.origin}/import#cloud-backup`
|
|
const providerLabel = input.providerLabel || 'Google Drive'
|
|
|
|
let subject: string
|
|
let paragraphs: string[]
|
|
if (input.kind === 'needs_reauth') {
|
|
subject = `Säkerhetskopieringen till ${providerLabel} är pausad`
|
|
paragraphs = [
|
|
`Den automatiska säkerhetskopieringen för ${companyName} är pausad: åtkomsten till ditt ${providerLabel}-konto har gått ut eller återkallats.`,
|
|
`Koppla om ${providerLabel} för att återuppta säkerhetskopieringen.`,
|
|
]
|
|
} else {
|
|
subject = `Säkerhetskopieringen till ${providerLabel} misslyckas`
|
|
paragraphs = [
|
|
`Den automatiska säkerhetskopieringen för ${companyName} till ${providerLabel} har misslyckats ${input.consecutiveFailures} nätter i rad.`,
|
|
input.errorMessage ? `Senaste fel: ${input.errorMessage}` : '',
|
|
'Kontrollera anslutningen under Importera/Exportera.',
|
|
].filter(Boolean)
|
|
}
|
|
|
|
const text = [...paragraphs, '', link].join('\n\n')
|
|
const html = [
|
|
...paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`),
|
|
`<p><a href="${escapeHtml(link)}">Öppna säkerhetskopiering</a></p>`,
|
|
].join('')
|
|
|
|
const result = await email.sendEmail({ to: recipient, subject, text, html })
|
|
if (!result.success) {
|
|
log.warn('backup alert send failed', { companyId: input.companyId, error: result.error })
|
|
return { sent: false, reason: 'send_failed' }
|
|
}
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.warn('backup alert failed', {
|
|
companyId: input.companyId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return { sent: false, reason: 'error' }
|
|
}
|
|
}
|
|
|
|
async function fetchCompanyName(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<string> {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('company_name')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
return (data?.company_name as string) || 'ditt företag'
|
|
}
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|