* 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>
128 lines
4.5 KiB
TypeScript
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()
|
|
}
|
|
}
|