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>
72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
/**
|
|
* Pure notice predicates: no queries, no side effects, no server-only
|
|
* imports. Client pages ('use client') import from THIS file; the server
|
|
* detection layer (lib/notices/categories.ts, which pulls in server-only
|
|
* dependencies) builds on the same functions and re-exports them, so every
|
|
* surface runs one shared decision.
|
|
*/
|
|
|
|
export interface ExpiringBankConnection {
|
|
id: string
|
|
bank_name: string
|
|
days_left: number
|
|
}
|
|
|
|
/**
|
|
* The canonical "consent expiring soon" day-math over already-fetched
|
|
* bank_connections rows: within (0, 14] days. Used by the
|
|
* bank_connection_expiring notice AND by the Hem page's Att göra Bevaka
|
|
* row, so the two surfaces can never disagree on the threshold.
|
|
*/
|
|
export function expiringBankConnectionsFrom(
|
|
rows: { id: string; bank_name: string | null; consent_expires: string | null }[],
|
|
now: Date = new Date(),
|
|
): ExpiringBankConnection[] {
|
|
const nowMs = now.getTime()
|
|
const result: ExpiringBankConnection[] = []
|
|
for (const row of rows) {
|
|
if (!row.consent_expires) continue
|
|
const daysLeft = Math.ceil(
|
|
(new Date(row.consent_expires).getTime() - nowMs) / (1000 * 60 * 60 * 24),
|
|
)
|
|
if (daysLeft > 0 && daysLeft <= 14) {
|
|
result.push({ id: row.id, bank_name: row.bank_name ?? '', days_left: daysLeft })
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** The skatteverket extension's /status response shape (subset we decide on). */
|
|
export interface SkvStatusLike {
|
|
connected?: boolean
|
|
disabled?: boolean
|
|
needsReconsent?: boolean
|
|
expired?: boolean
|
|
canRefresh?: boolean
|
|
}
|
|
|
|
/**
|
|
* The canonical "Skatteverket needs reconnect" predicate over a fetched
|
|
* /status shape. A connection needs reconnecting when it exists, is not
|
|
* env-disabled, and either was flagged needs_reconsent by a cron/API call or
|
|
* has an expired access token with nothing left to refresh with.
|
|
*/
|
|
export function skvStatusNeedsReconnect(s: SkvStatusLike): boolean {
|
|
return Boolean(s.connected && !s.disabled && (s.needsReconsent || (s.expired && !s.canRefresh)))
|
|
}
|
|
|
|
/**
|
|
* The canonical "this auth failure means reconnect" predicate over a failed
|
|
* skatteverket API response. 401 covers several distinct auth states (see
|
|
* handleSkvError in the skatteverket extension): only NOT_CONNECTED means "no
|
|
* connection exists"; the rest (SESSION_EXPIRED, MISSING_SCOPE, TOKEN_REVOKED,
|
|
* TOKEN_CORRUPTED, ...) fire while a stored connection exists and mean the
|
|
* user must reconnect with BankID.
|
|
*/
|
|
export function skvAuthErrorNeedsReconnect(
|
|
status: number,
|
|
code: string | null | undefined,
|
|
): boolean {
|
|
return status === 401 && code !== 'NOT_CONNECTED'
|
|
}
|