Files
accounted/lib/notices/aggregate.ts
T
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

128 lines
4.5 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { NOTICE_PRIORITY, type Notice } from './types'
import {
detectBackupFailing,
detectBrokenBankConnections,
detectExpiringBankConnections,
detectOtherAccountHint,
detectSkvDisconnected,
} from './categories'
const log = createLogger('notices')
export interface GetCompanyNoticesOptions {
/** Caller's user id: Skatteverket connections and dismissals are per user. */
userId: string
now?: Date
}
/**
* All active, non-dismissed notices for a company, in NOTICE_PRIORITY order.
* Predicates run in one parallel burst and individually soft-fail to null
* (see categories.ts), so this is safe to call from server components on
* every render, mirroring getWorklistCounts.
*
* Dismissals are per (company, user, notice id); notice ids embed a state
* discriminator, so a dismissal hides exactly the state the user saw and a
* NEW failure surfaces again. A failed dismissal read degrades to showing
* everything: over-showing a real problem beats silently hiding it.
*
* This read path also reaps stale dismissals: when a category is currently
* healthy, the caller's stored dismissals for that category describe a state
* that no longer exists and are deleted (best effort). See the reaping
* contract on Notice.id in lib/notices/types.ts.
*/
export async function getCompanyNotices(
supabase: SupabaseClient,
companyId: string,
{ userId, now = new Date() }: GetCompanyNoticesOptions,
): Promise<Notice[]> {
const [candidates, dismissedIds] = await Promise.all([
Promise.all([
detectBrokenBankConnections(supabase, companyId),
detectSkvDisconnected(supabase, userId, companyId, now),
detectBackupFailing(supabase, companyId),
detectExpiringBankConnections(supabase, companyId, now),
detectOtherAccountHint(supabase, companyId),
]),
fetchDismissedIds(supabase, companyId, userId),
])
const byCategory = new Map(
candidates.filter((n): n is Notice => n !== null).map((n) => [n.category, n]),
)
// Stale = dismissed under a category (id prefix 'category:') that is
// currently healthy. other_account_hint's exact id carries no ':' and is
// deliberately never reaped: its condition either holds or stops holding.
const staleIds = [...dismissedIds].filter((id) => {
const category = NOTICE_PRIORITY.find((c) => id.startsWith(`${c}:`))
return category !== undefined && !byCategory.has(category)
})
await reapStaleDismissals(supabase, companyId, userId, staleIds)
return NOTICE_PRIORITY.map((category) => byCategory.get(category)).filter(
(n): n is Notice => n !== undefined && !dismissedIds.has(n.id),
)
}
/**
* Best-effort deletion of dismissals whose category has recovered (see the
* reaping contract in lib/notices/types.ts): keeping them would hide the NEXT
* failure for ids without a volatile discriminator (backup_failing). Failures
* are swallowed: reaping is hygiene, never worth failing a read for, and an
* un-reaped row is retried on the next read. A predicate that soft-failed to
* null counts as healthy here; the worst case is one extra re-dismiss, which
* errs on the same side as everything else in this module: over-showing.
*/
async function reapStaleDismissals(
supabase: SupabaseClient,
companyId: string,
userId: string,
staleIds: string[],
): Promise<void> {
if (staleIds.length === 0) return
try {
const { error } = await supabase
.from('notice_dismissals')
.delete()
.eq('company_id', companyId)
.eq('user_id', userId)
.in('notice_id', staleIds)
if (error) {
log.warn('stale notice dismissal reap failed', { companyId, reason: error.message })
}
} catch (err) {
log.warn('stale notice dismissal reap failed', {
companyId,
reason: err instanceof Error ? err.message : undefined,
})
}
}
async function fetchDismissedIds(
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<Set<string>> {
try {
const { data, error } = await supabase
.from('notice_dismissals')
.select('notice_id')
.eq('company_id', companyId)
.eq('user_id', userId)
if (error) {
log.error('notice dismissal read failed', { companyId, reason: error.message })
return new Set()
}
return new Set((data ?? []).map((r) => r.notice_id as string))
} catch (err) {
log.error('notice dismissal read failed', {
companyId,
reason: err instanceof Error ? err.message : undefined,
})
return new Set()
}
}