9fc05c383f
* 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>
336 lines
13 KiB
TypeScript
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,
|
|
)
|
|
}
|
|
}
|