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>
117 lines
5.3 KiB
TypeScript
117 lines
5.3 KiB
TypeScript
/**
|
|
* Notices: the unified degraded-state model (system & integration health).
|
|
*
|
|
* One source of truth for "something the user relies on is broken or about
|
|
* to break", shared by the Hem notice line and (via /api/notices) any client
|
|
* surface. The sibling of lib/worklist, which owns actionable bookkeeping
|
|
* work items; notices own connection/health state. Every surface that shows
|
|
* degraded integration state MUST read it from lib/notices instead of
|
|
* hand-rolling its own detection, so the same fact is never double-modeled
|
|
* with diverging thresholds.
|
|
*
|
|
* Each category documents its pending ("notice active") and done conditions.
|
|
*/
|
|
|
|
export const NOTICE_CATEGORIES = [
|
|
/**
|
|
* A bank connection has already failed.
|
|
* Pending: bank_connections.status IN ('expired', 'error'): same predicate
|
|
* as BankSyncStatusChip's "attention" state.
|
|
* Done: the connection is renewed (status back to 'active') or removed.
|
|
*/
|
|
'bank_connection_broken',
|
|
/**
|
|
* The Skatteverket connection can no longer authenticate.
|
|
* Pending: a skatteverket_tokens row exists for (user, company) with
|
|
* status = 'needs_reconsent', or its access token is expired with
|
|
* no usable refresh token (refresh_token NULL or refresh_count
|
|
* >= 10): mirrors the skatteverket extension's /status route.
|
|
* Done: the user re-consents with BankID (storeTokens resets the row)
|
|
* or disconnects entirely (no row = not connected = no notice).
|
|
*/
|
|
'skv_disconnected',
|
|
/**
|
|
* A connected cloud backup is failing (dead token or errored auto-sync).
|
|
* Pending: a cloud-backup provider connection exists in extension_data
|
|
* with status = 'needs_reauth', or its schedule's
|
|
* last_auto_sync_status = 'error'.
|
|
* Done: reconnect/re-auth, or the next auto-sync succeeds.
|
|
*/
|
|
'backup_failing',
|
|
/**
|
|
* A PSD2 bank consent expires within 14 days.
|
|
* Pending: bank_connections.status = 'active' AND consent_expires within
|
|
* (0, 14] days. A connection that has ALREADY failed is counted
|
|
* by bank_connection_broken instead (status filter makes the two
|
|
* categories disjoint: broken supersedes expiring by construction).
|
|
* Done: the consent is renewed (consent_expires moves out) or the
|
|
* connection expires (moves to bank_connection_broken).
|
|
*/
|
|
'bank_connection_expiring',
|
|
/**
|
|
* The signed-in account looks bookkeeping-empty while a same-orgnr company
|
|
* with real bookkeeping exists in another account (#1231).
|
|
* Pending: lib/company/other-account-hint shouldShowOtherAccountHint().
|
|
* Done: the account gets bookkeeping of its own, or the user switches.
|
|
*/
|
|
'other_account_hint',
|
|
] as const
|
|
|
|
export type NoticeCategory = (typeof NOTICE_CATEGORIES)[number]
|
|
|
|
export type NoticeSeverity = 'error' | 'warning'
|
|
|
|
/**
|
|
* Fixed cross-category priority: surfaces that show a single notice show the
|
|
* first active one in this order. Tune here, never per call site.
|
|
*/
|
|
export const NOTICE_PRIORITY: readonly NoticeCategory[] = [
|
|
'bank_connection_broken',
|
|
'skv_disconnected',
|
|
'backup_failing',
|
|
'bank_connection_expiring',
|
|
'other_account_hint',
|
|
]
|
|
|
|
export interface Notice {
|
|
/**
|
|
* Stable identity of THIS occurrence: the category plus a state
|
|
* discriminator (connection id + status, expiry date). A dismissal is
|
|
* stored against the id, so dismissals silence a state, never a category.
|
|
*
|
|
* Two invariants keep dismissals honest, and every id must satisfy both:
|
|
*
|
|
* 1. Stable per incident: the discriminator must not embed anything the
|
|
* system re-stamps while the SAME incident persists (a cron-updated
|
|
* last-attempt timestamp), or a dismissed notice resurrects daily.
|
|
* backup_failing therefore discriminates on (provider, reason) only.
|
|
* 2. Reaped on recovery: the read path (getCompanyNotices) best-effort
|
|
* deletes the caller's stored dismissals for any category that is
|
|
* currently healthy, matched by the `category:` id prefix. That is what
|
|
* makes a NEW failure after a healthy spell resurface even when it mints
|
|
* the exact same id as the dismissed one: error -> dismiss (hidden while
|
|
* failing) -> healthy (dismissal reaped on the next read) -> new error
|
|
* -> visible again. Categories whose id changes per incident anyway
|
|
* (bank ids embed connection id + status/expiry, skv embeds the
|
|
* incident's first-error/expiry timestamp) get the same reaping as
|
|
* hygiene. other_account_hint has no `category:` prefix and is never
|
|
* reaped: dismissing it silences the condition itself.
|
|
*
|
|
* Ids are also bounded: multi-connection discriminators collapse to a
|
|
* count + 8-char sha256 digest of the sorted parts (categories.ts), so an
|
|
* id never grows with the number of connections and stays well under the
|
|
* dismiss schema cap (lib/api/schemas.ts).
|
|
*/
|
|
id: string
|
|
category: NoticeCategory
|
|
severity: NoticeSeverity
|
|
/** Key in the `notices` i18n namespace. */
|
|
messageKey: string
|
|
/** ICU params for messageKey (bank names, counts, days). */
|
|
messageParams?: Record<string, string | number>
|
|
/** Key in the `notices` namespace for the action link label. */
|
|
actionKey: string
|
|
/** Where the action link goes (client surfaces may override per category). */
|
|
actionHref: string
|
|
}
|