Files
accounted/lib/notices/categories.ts
T
Jakob Wennberg 9fc05c383f feat(notices): one aggregated notice line instead of stacked degraded-state banners (#1733)
* feat(notices): lib/notices aggregator + single notice line on Hem

Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:

- lib/notices/{types,predicates,categories,aggregate}: five documented
  categories with a fixed priority order; every predicate soft-fails to
  null; pure decision helpers live in predicates.ts so 'use client' pages
  can import them without pulling server-only modules. Broken supersedes
  expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
  notice_dismissals table (per company+user+notice_id, RLS user-scoped).
  Notice ids embed a state discriminator, so a dismissal hides exactly
  the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
  boxed BackupHealthBanner card sat (banner deleted; its multi-provider
  sentence logic moved into the backup_failing predicate), with a quiet
  "+N till" inline expander. otherAccountHint joins the same list as the
  lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
  the reconnect decision from the shared skvStatusNeedsReconnect /
  skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
  expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
  page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
  räkenskapsinformation) to satisfy the full-archive contract.

SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy

Review fixes on the notice aggregator:

- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
  (version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
  last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
  SAME incident persists and so resurrected a dismissed notice daily. The
  id is now stable per (provider, reason), and the opposite direction is
  kept correct by stale-dismissal reaping in getCompanyNotices: when a
  category is currently healthy, the caller's stored dismissals for that
  category (matched on the 'category:' id prefix) are best-effort deleted,
  so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
  the other ids: bank ids embed connection id + status/expiry and skv
  embeds the incident's first-error/expiry timestamp (markNeedsReconsent
  only fires post-connect), all stable per incident; they get the same
  reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
  the English message: a bank_broken_one_unnamed message variant (sv + en)
  is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
  now collapses to count + first 8 hex of a sha256 over the sorted parts
  (node:crypto, server-only) instead of concatenating uuids; single
  connection ids stay human-readable. Dismiss schema cap tightened to 200
  with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
  healthy state reaps, new failure after reap resurfaces, hint never
  reaped, failed reap swallowed, 30-connection id under 200 chars and
  stable across orderings, unnamed-bank variant, sorted backup id stable
  across cron re-stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(notices): pg-real coverage for the notice_dismissals policies

The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.

Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:27:52 +02:00

336 lines
13 KiB
TypeScript

/**
* Per-category notice predicates: the single owner of every degraded-state
* detection. Surfaces (Hem, /api/notices, per-page attn lines) must call
* these, or the exported pure helpers, instead of inlining their own checks;
* see lib/notices/types.ts for each category's pending/done definition.
*
* Every predicate soft-fails to null with a logged error: a broken health
* check must never take down the dashboard or the home page.
*/
import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
import { expiringBankConnectionsFrom, skvStatusNeedsReconnect } from './predicates'
import type { Notice } from './types'
// The pure decision layer lives in ./predicates (client-safe: 'use client'
// pages import it directly, since this file pulls in server-only modules).
// Re-exported here so server code has one import path for the whole domain.
export * from './predicates'
const log = createLogger('notices')
function logAndNull(
category: string,
companyId: string,
error: { message?: string } | null,
): null {
// companyId is a structured field so repeated failures can be correlated
// to a tenant in monitoring (mirrors lib/worklist logAndZero).
log.error(`notice predicate failed: ${category}`, { companyId, reason: error?.message })
return null
}
/**
* Bound a multi-part id discriminator. A single part stays human-readable
* (a connection id plus its status/expiry); several parts, each embedding a
* uuid, collapse to `<count>@<first 8 hex of sha256 over the sorted parts>`,
* so the id stays far below the dismiss schema cap (lib/api/schemas.ts) no
* matter how many connections fold into one notice, and is stable across
* row orderings. Server-only (node:crypto), which this module already is.
*/
function boundedDiscriminator(parts: string[]): string {
if (parts.length === 1) return parts[0]
const digest = createHash('sha256')
.update([...parts].sort().join(','))
.digest('hex')
.slice(0, 8)
return `${parts.length}@${digest}`
}
// ── Predicates (one query each; soft-fail to null) ──
/**
* bank_connection_broken: connections whose status is already terminal
* ('expired' | 'error'): same predicate as BankSyncStatusChip's "attention"
* state. Disjoint from bank_connection_expiring by the status filter.
*/
export async function detectBrokenBankConnections(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, status, bank_name')
.eq('company_id', companyId)
.in('status', ['expired', 'error'])
if (error) return logAndNull('bank_connection_broken', companyId, error)
const rows = data ?? []
if (rows.length === 0) return null
const discriminator = boundedDiscriminator(rows.map((r) => `${r.id}=${r.status}`))
// A NULL bank_name switches to the unnamed message variant instead of
// interpolating a fallback word, which would leak Swedish into English.
const bankName = rows.length === 1 ? ((rows[0].bank_name as string | null) || null) : null
return {
id: `bank_connection_broken:${discriminator}`,
category: 'bank_connection_broken',
severity: 'error',
messageKey:
rows.length === 1
? bankName
? 'bank_broken_one'
: 'bank_broken_one_unnamed'
: 'bank_broken_many',
messageParams:
rows.length === 1
? bankName
? { bank: bankName }
: undefined
: { count: rows.length },
actionKey: 'bank_broken_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_broken',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* bank_connection_expiring: active connections whose PSD2 consent runs out
* within 14 days. Only status = 'active' rows are considered, so a
* connection that has ALREADY failed never shows as both broken and
* expiring (broken supersedes expiring for the same connection).
*/
export async function detectExpiringBankConnections(
supabase: SupabaseClient,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, bank_name, consent_expires')
.eq('company_id', companyId)
.eq('status', 'active')
.not('consent_expires', 'is', null)
if (error) return logAndNull('bank_connection_expiring', companyId, error)
const expiring = expiringBankConnectionsFrom(data ?? [], now)
if (expiring.length === 0) return null
// The consent date, not days_left, discriminates the id: the countdown
// ticking from 14 to 13 days must not resurrect a dismissed notice.
const consentByid = new Map(
(data ?? []).map((r) => [r.id as string, r.consent_expires as string | null]),
)
const discriminator = boundedDiscriminator(
expiring.map((c) => `${c.id}=${consentByid.get(c.id) ?? ''}`),
)
return {
id: `bank_connection_expiring:${discriminator}`,
category: 'bank_connection_expiring',
severity: 'warning',
messageKey: expiring.length === 1 ? 'bank_expiring_one' : 'bank_expiring_many',
messageParams:
expiring.length === 1
? { bank: expiring[0].bank_name, days: expiring[0].days_left }
: { count: expiring.length },
actionKey: 'bank_expiring_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_expiring',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* skv_disconnected: a stored Skatteverket connection that can no longer
* authenticate. Mirrors the skatteverket extension's /status route exactly
* (needs_reconsent flag, or expired with no usable refresh token) and runs
* the shared skvStatusNeedsReconnect decision over the row. Connections are
* per (user, company), so the predicate needs the caller's user id.
* Refresh-token ciphertext is read only for a null check and never returned.
*/
export async function detectSkvDisconnected(
supabase: SupabaseClient,
userId: string,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
if ((process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true') return null
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('status, expires_at, refresh_token, refresh_count, last_error_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.maybeSingle()
if (error) return logAndNull('skv_disconnected', companyId, error)
if (!data) return null
const status = (data.status as string | null) ?? 'active'
const expiresAt = data.expires_at as string | null
const expired = expiresAt !== null && new Date(expiresAt).getTime() < now.getTime()
const canRefresh = data.refresh_token !== null && ((data.refresh_count as number | null) ?? 0) < 10
const needsReconsent = status === 'needs_reconsent'
if (!skvStatusNeedsReconnect({ connected: true, needsReconsent, expired, canRefresh })) {
return null
}
// needs_reconsent rows discriminate on when the terminal error was
// detected; refresh-exhausted rows on when the token expired: either way
// a NEW failure after a successful re-consent mints a new id.
const discriminator = needsReconsent
? `needs_reconsent@${(data.last_error_at as string | null) ?? ''}`
: `expired@${expiresAt ?? ''}`
return {
id: `skv_disconnected:${discriminator}`,
category: 'skv_disconnected',
severity: 'error',
messageKey: 'skv_disconnected',
actionKey: 'skv_disconnected_action',
actionHref: '/settings/tax',
}
} catch (err) {
return logAndNull(
'skv_disconnected',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/** Brand names stay untranslated; the sentence around them is localised. */
const BACKUP_PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox',
}
interface BackupConnectionValue {
status?: 'active' | 'needs_reauth'
needs_reauth_at?: string
}
interface BackupScheduleValue {
last_auto_sync_status?: 'success' | 'error' | null
last_auto_sync_at?: string | null
}
/**
* backup_failing: a connected cloud-backup destination with a dead token or
* an errored last auto-sync. Reads the cloud-backup extension's rows in
* extension_data directly (core must not import from @/extensions/, so the
* keys and value shapes are mirrored here, same as the old
* BackupHealthBanner mirrored the status API's shape). Multiple failing
* providers fold into ONE notice: a working Drive backup does not make a
* broken Dropbox backup acceptable, but two failures must not stack two
* lines either.
*/
export async function detectBackupFailing(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return null
const { data, error } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', companyId)
.eq('extension_id', 'cloud-backup')
.in('key', [
'google_drive_connection',
'google_drive_schedule',
'dropbox_connection',
'dropbox_schedule',
])
if (error) return logAndNull('backup_failing', companyId, error)
const byKey = new Map((data ?? []).map((r) => [r.key as string, r.value]))
const failing: { provider: string; reason: 'reauth' | 'sync_error' }[] = []
for (const provider of ['google_drive', 'dropbox']) {
const connection = byKey.get(`${provider}_connection`) as BackupConnectionValue | undefined
if (!connection) continue
const schedule = byKey.get(`${provider}_schedule`) as BackupScheduleValue | undefined
if (connection.status === 'needs_reauth') {
failing.push({ provider, reason: 'reauth' })
} else if (schedule?.last_auto_sync_status === 'error') {
failing.push({ provider, reason: 'sync_error' })
}
}
if (failing.length === 0) return null
const names = failing
.map((f) => BACKUP_PROVIDER_LABELS[f.provider] ?? f.provider)
.join(' + ')
const allNeedReauth = failing.every((f) => f.reason === 'reauth')
// Deliberately NO timestamp in the discriminator: the cron re-stamps
// last_auto_sync_at on every failed run (and can re-stamp needs_reauth_at
// on retries), which would resurrect a dismissed notice daily while the
// SAME incident persists. The id is stable per (provider, reason) and the
// opposite direction (a NEW failure after a healthy spell must resurface)
// is guaranteed by the stale-dismissal reaping in aggregate.ts; contract
// in lib/notices/types.ts.
const discriminator = failing
.map((f) => `${f.provider}=${f.reason}`)
.sort()
.join(',')
return {
id: `backup_failing:${discriminator}`,
category: 'backup_failing',
severity: 'error',
messageKey: allNeedReauth ? 'backup_reauth' : 'backup_failing',
messageParams: { provider: names },
actionKey: 'backup_action',
actionHref: '/import#cloud-backup',
}
} catch (err) {
return logAndNull(
'backup_failing',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* other_account_hint: the wrong-login nudge (#1231). Delegates the detection
* to lib/company/other-account-hint (which already fails soft to false); this
* wrapper only shapes it as the lowest-priority notice. The id carries no
* state discriminator: the condition is "this account is empty while another
* holds the bookkeeping", which either holds or stops holding.
*/
export async function detectOtherAccountHint(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const show = await shouldShowOtherAccountHint(supabase)
if (!show) return null
return {
id: 'other_account_hint',
category: 'other_account_hint',
severity: 'warning',
messageKey: 'other_account_hint',
actionKey: 'other_account_hint_action',
// Client surfaces override this with a sign-out handler; the href is
// the no-JS fallback destination.
actionHref: '/login',
}
} catch (err) {
return logAndNull(
'other_account_hint',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}