* 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>
176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createMockSupabase, createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { Notice } from '../types'
|
|
|
|
const detectMocks = vi.hoisted(() => ({
|
|
broken: vi.fn(),
|
|
skv: vi.fn(),
|
|
backup: vi.fn(),
|
|
expiring: vi.fn(),
|
|
other: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../categories', () => ({
|
|
detectBrokenBankConnections: detectMocks.broken,
|
|
detectSkvDisconnected: detectMocks.skv,
|
|
detectBackupFailing: detectMocks.backup,
|
|
detectExpiringBankConnections: detectMocks.expiring,
|
|
detectOtherAccountHint: detectMocks.other,
|
|
}))
|
|
|
|
import { getCompanyNotices } from '../aggregate'
|
|
|
|
const notice = (category: Notice['category'], id: string): Notice => ({
|
|
id,
|
|
category,
|
|
severity: 'warning',
|
|
messageKey: category,
|
|
actionKey: `${category}_action`,
|
|
actionHref: '/x',
|
|
})
|
|
|
|
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
|
const supabase = mockSupabase as unknown as SupabaseClient
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
detectMocks.broken.mockResolvedValue(null)
|
|
detectMocks.skv.mockResolvedValue(null)
|
|
detectMocks.backup.mockResolvedValue(null)
|
|
detectMocks.expiring.mockResolvedValue(null)
|
|
detectMocks.other.mockResolvedValue(null)
|
|
mockResult({ data: [] }) // notice_dismissals: none
|
|
})
|
|
|
|
describe('getCompanyNotices', () => {
|
|
it('returns an empty list when nothing is degraded', async () => {
|
|
await expect(
|
|
getCompanyNotices(supabase, 'company-1', { userId: 'user-1' }),
|
|
).resolves.toEqual([])
|
|
})
|
|
|
|
it('orders notices by the documented priority regardless of resolution order', async () => {
|
|
detectMocks.other.mockResolvedValue(notice('other_account_hint', 'other_account_hint'))
|
|
detectMocks.expiring.mockResolvedValue(notice('bank_connection_expiring', 'exp:1'))
|
|
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
|
detectMocks.backup.mockResolvedValue(notice('backup_failing', 'backup:1'))
|
|
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
|
|
|
|
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
|
expect(notices.map((n) => n.category)).toEqual([
|
|
'bank_connection_broken',
|
|
'skv_disconnected',
|
|
'backup_failing',
|
|
'bank_connection_expiring',
|
|
'other_account_hint',
|
|
])
|
|
})
|
|
|
|
it('hides dismissed notice ids and keeps the rest', async () => {
|
|
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
|
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
|
|
mockResult({ data: [{ notice_id: 'broken:1' }] })
|
|
|
|
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
|
expect(notices.map((n) => n.id)).toEqual(['skv:1'])
|
|
})
|
|
|
|
it('does NOT hide a notice whose state discriminator changed since the dismissal', async () => {
|
|
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:2'))
|
|
mockResult({ data: [{ notice_id: 'broken:1' }] })
|
|
|
|
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
|
expect(notices.map((n) => n.id)).toEqual(['broken:2'])
|
|
})
|
|
|
|
it('shows everything when the dismissal read fails (over-show beats hiding a real problem)', async () => {
|
|
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
|
mockResult({ error: { message: 'boom' } })
|
|
|
|
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
|
expect(notices.map((n) => n.id)).toEqual(['broken:1'])
|
|
})
|
|
|
|
it('passes the caller identity through to the per-user predicates', async () => {
|
|
const now = new Date('2026-08-19T12:00:00Z')
|
|
await getCompanyNotices(supabase, 'company-1', { userId: 'user-1', now })
|
|
expect(detectMocks.skv).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', now)
|
|
expect(detectMocks.expiring).toHaveBeenCalledWith(supabase, 'company-1', now)
|
|
})
|
|
})
|
|
|
|
describe('stale-dismissal reaping (contract in lib/notices/types.ts)', () => {
|
|
// A queued mock records builder calls, so the delete (or its absence) is
|
|
// observable. The backup id is timestamp-free and identical per incident,
|
|
// making it the category whose resurface behavior depends on reaping.
|
|
const BACKUP_ID = 'backup_failing:google_drive=sync_error'
|
|
const backupNotice = notice('backup_failing', BACKUP_ID)
|
|
const queued = createQueuedMockSupabase()
|
|
const qSupabase = queued.supabase as unknown as SupabaseClient
|
|
|
|
beforeEach(() => {
|
|
queued.reset()
|
|
})
|
|
|
|
it('keeps a persisting failure dismissed across two aggregations (no reap while failing)', async () => {
|
|
detectMocks.backup.mockResolvedValue(backupNotice)
|
|
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
|
const first = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
|
const second = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
|
|
|
expect(first).toEqual([])
|
|
expect(second).toEqual([])
|
|
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
|
|
})
|
|
|
|
it('reaps the stored dismissal once the category is healthy again', async () => {
|
|
// All detects resolve null (healthy) via the outer beforeEach.
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] }) // dismissal read
|
|
queued.enqueue({ data: null }) // delete result
|
|
|
|
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
|
|
|
expect(notices).toEqual([])
|
|
expect(queued.findCalls('notice_dismissals', 'delete').length).toBe(1)
|
|
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
|
|
})
|
|
|
|
it('resurfaces a NEW failure after the healthy spell reaped the dismissal', async () => {
|
|
// error -> dismiss: hidden while the incident persists.
|
|
detectMocks.backup.mockResolvedValue(backupNotice)
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
|
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
|
|
|
|
// success: the read reaps the now-stale dismissal.
|
|
detectMocks.backup.mockResolvedValue(null)
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
|
queued.enqueue({ data: null })
|
|
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
|
|
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
|
|
|
|
// new error later, same id: the dismissal is gone, so it surfaces again.
|
|
detectMocks.backup.mockResolvedValue(backupNotice)
|
|
queued.enqueue({ data: [] })
|
|
expect(
|
|
(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).map((n) => n.id),
|
|
).toEqual([BACKUP_ID])
|
|
})
|
|
|
|
it('never reaps other_account_hint: its id has no category-prefix discriminator', async () => {
|
|
queued.enqueue({ data: [{ notice_id: 'other_account_hint' }] })
|
|
await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
|
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
|
|
})
|
|
|
|
it('swallows a failed reap and still returns the computed notices', async () => {
|
|
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'bank_connection_broken:c1=expired'))
|
|
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
|
queued.enqueue({ error: { message: 'boom' } }) // delete fails
|
|
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
|
expect(notices.map((n) => n.id)).toEqual(['bank_connection_broken:c1=expired'])
|
|
})
|
|
})
|