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>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:27:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 4cf227001d
commit 9fc05c383f
26 changed files with 1921 additions and 173 deletions
+14
View File
@@ -3669,3 +3669,17 @@ export const CompanyMigrationResetSchema = z.object({
error: 'Bekräfta att den tidigare företagskopian sparas som arkiv',
}),
})
/**
* POST /api/notices/dismiss
*
* notice_id is an opaque lib/notices id (category + state discriminator).
* Ids are bounded by construction: a multi-connection discriminator collapses
* to a count plus an 8-char sha256 digest of the sorted parts (see
* boundedDiscriminator in lib/notices/categories.ts), so the widest real id
* is a single connection uuid plus a status/expiry, well under this cap;
* anything longer is garbage.
*/
export const NoticeDismissSchema = z.object({
notice_id: z.string().min(1).max(200),
})
+175
View File
@@ -0,0 +1,175 @@
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'])
})
})
+430
View File
@@ -0,0 +1,430 @@
import { createHash } from 'node:crypto'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const otherAccountHintMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/extensions/_generated/enabled-extensions', () => ({
ENABLED_EXTENSION_IDS: new Set(['cloud-backup']),
}))
vi.mock('@/lib/company/other-account-hint', () => ({
shouldShowOtherAccountHint: otherAccountHintMock,
}))
import {
detectBackupFailing,
detectBrokenBankConnections,
detectExpiringBankConnections,
detectOtherAccountHint,
detectSkvDisconnected,
expiringBankConnectionsFrom,
skvAuthErrorNeedsReconnect,
skvStatusNeedsReconnect,
} from '../categories'
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const COMPANY = 'company-1'
const USER = 'user-1'
const NOW = new Date('2026-08-19T12:00:00Z')
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('expiringBankConnectionsFrom (pure day-math)', () => {
const conn = (days: number | null) => ({
id: 'c1',
bank_name: 'SEB',
consent_expires:
days === null ? null : new Date(NOW.getTime() + days * 24 * 60 * 60 * 1000).toISOString(),
})
it('includes a consent expiring in exactly 14 days', () => {
expect(expiringBankConnectionsFrom([conn(14)], NOW)).toEqual([
{ id: 'c1', bank_name: 'SEB', days_left: 14 },
])
})
it('excludes a consent expiring in 15 days', () => {
expect(expiringBankConnectionsFrom([conn(15)], NOW)).toEqual([])
})
it('excludes an already-expired consent (days_left <= 0): that is broken, not expiring', () => {
expect(expiringBankConnectionsFrom([conn(-1)], NOW)).toEqual([])
})
it('excludes rows without a consent date', () => {
expect(expiringBankConnectionsFrom([conn(null)], NOW)).toEqual([])
})
it('rounds partial days up (1 hour left = 1 day)', () => {
const row = {
id: 'c1',
bank_name: 'SEB',
consent_expires: new Date(NOW.getTime() + 60 * 60 * 1000).toISOString(),
}
expect(expiringBankConnectionsFrom([row], NOW)).toEqual([
{ id: 'c1', bank_name: 'SEB', days_left: 1 },
])
})
})
describe('skvStatusNeedsReconnect (pure)', () => {
it('fires on needsReconsent', () => {
expect(skvStatusNeedsReconnect({ connected: true, needsReconsent: true })).toBe(true)
})
it('fires on expired without refresh capability', () => {
expect(skvStatusNeedsReconnect({ connected: true, expired: true, canRefresh: false })).toBe(true)
})
it('stays quiet when expired but refreshable', () => {
expect(skvStatusNeedsReconnect({ connected: true, expired: true, canRefresh: true })).toBe(false)
})
it('stays quiet when not connected', () => {
expect(skvStatusNeedsReconnect({ connected: false, needsReconsent: true })).toBe(false)
})
it('stays quiet when env-disabled', () => {
expect(
skvStatusNeedsReconnect({ connected: true, disabled: true, needsReconsent: true }),
).toBe(false)
})
})
describe('skvAuthErrorNeedsReconnect (pure)', () => {
it('treats 401 with a non-NOT_CONNECTED code as reconnect', () => {
expect(skvAuthErrorNeedsReconnect(401, 'SESSION_EXPIRED')).toBe(true)
expect(skvAuthErrorNeedsReconnect(401, undefined)).toBe(true)
})
it('treats 401 NOT_CONNECTED as not-connected, not reconnect', () => {
expect(skvAuthErrorNeedsReconnect(401, 'NOT_CONNECTED')).toBe(false)
})
it('never fires on non-401 statuses', () => {
expect(skvAuthErrorNeedsReconnect(500, 'SESSION_EXPIRED')).toBe(false)
})
})
describe('detectBrokenBankConnections', () => {
it('returns null when no connection is broken', async () => {
enqueue({ data: [] })
await expect(detectBrokenBankConnections(supabase, COMPANY)).resolves.toBeNull()
expect(findCall('bank_connections', 'in')).toEqual(['status', ['expired', 'error']])
})
it('shapes a single broken connection with its bank name', async () => {
enqueue({ data: [{ id: 'c1', status: 'expired', bank_name: 'SEB' }] })
const notice = await detectBrokenBankConnections(supabase, COMPANY)
expect(notice).toMatchObject({
id: 'bank_connection_broken:c1=expired',
category: 'bank_connection_broken',
severity: 'error',
messageKey: 'bank_broken_one',
messageParams: { bank: 'SEB' },
actionHref: '/settings/banking',
})
})
it('uses the unnamed message variant when the single broken connection has no bank name', async () => {
enqueue({ data: [{ id: 'c1', status: 'expired', bank_name: null }] })
const notice = await detectBrokenBankConnections(supabase, COMPANY)
expect(notice).toMatchObject({
id: 'bank_connection_broken:c1=expired',
messageKey: 'bank_broken_one_unnamed',
})
expect(notice?.messageParams).toBeUndefined()
})
it('folds several broken connections into one counted notice with a bounded, stable id', async () => {
const digest = createHash('sha256').update('c1=expired,c2=error').digest('hex').slice(0, 8)
enqueue({
data: [
{ id: 'c2', status: 'error', bank_name: 'Nordea' },
{ id: 'c1', status: 'expired', bank_name: 'SEB' },
],
})
const notice = await detectBrokenBankConnections(supabase, COMPANY)
expect(notice).toMatchObject({
id: `bank_connection_broken:2@${digest}`,
messageKey: 'bank_broken_many',
messageParams: { count: 2 },
})
})
it('keeps the id under 200 chars for 30 broken connections, stable across orderings', async () => {
const rows = Array.from({ length: 30 }, (_, i) => ({
id: `3f8b2c1a-0000-4000-8000-${String(i).padStart(12, '0')}`,
status: i % 2 === 0 ? 'expired' : 'error',
bank_name: null,
}))
enqueue({ data: rows })
enqueue({ data: [...rows].reverse() })
const first = await detectBrokenBankConnections(supabase, COMPANY)
const second = await detectBrokenBankConnections(supabase, COMPANY)
expect(first?.id).toBeDefined()
expect(first?.id.length).toBeLessThan(200)
expect(first?.id).toMatch(/^bank_connection_broken:30@[0-9a-f]{8}$/)
expect(second?.id).toBe(first?.id)
})
it('soft-fails to null on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(detectBrokenBankConnections(supabase, COMPANY)).resolves.toBeNull()
})
})
describe('detectExpiringBankConnections', () => {
const expires = new Date(NOW.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
it('only considers active connections (broken supersedes expiring)', async () => {
enqueue({ data: [] })
await detectExpiringBankConnections(supabase, COMPANY, NOW)
expect(findCalls('bank_connections', 'eq')).toEqual([
['company_id', COMPANY],
['status', 'active'],
])
})
it('discriminates the id on the consent date, not the ticking countdown', async () => {
enqueue({ data: [{ id: 'c1', bank_name: 'SEB', consent_expires: expires }] })
const notice = await detectExpiringBankConnections(supabase, COMPANY, NOW)
expect(notice).toMatchObject({
id: `bank_connection_expiring:c1=${expires}`,
category: 'bank_connection_expiring',
severity: 'warning',
messageKey: 'bank_expiring_one',
messageParams: { bank: 'SEB', days: 10 },
})
})
it('collapses several expiring consents into a bounded digest id', async () => {
enqueue({
data: [
{ id: 'c1', bank_name: 'SEB', consent_expires: expires },
{ id: 'c2', bank_name: 'Nordea', consent_expires: expires },
],
})
const notice = await detectExpiringBankConnections(supabase, COMPANY, NOW)
expect(notice?.id).toMatch(/^bank_connection_expiring:2@[0-9a-f]{8}$/)
expect(notice).toMatchObject({ messageKey: 'bank_expiring_many', messageParams: { count: 2 } })
})
it('returns null when every consent is further out than 14 days', async () => {
const far = new Date(NOW.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
enqueue({ data: [{ id: 'c1', bank_name: 'SEB', consent_expires: far }] })
await expect(detectExpiringBankConnections(supabase, COMPANY, NOW)).resolves.toBeNull()
})
it('soft-fails to null on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(detectExpiringBankConnections(supabase, COMPANY, NOW)).resolves.toBeNull()
})
})
describe('detectSkvDisconnected', () => {
it('returns null when no token row exists (not connected)', async () => {
enqueue({ data: null })
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
})
it('fires on a needs_reconsent row, discriminated by the error timestamp', async () => {
enqueue({
data: {
status: 'needs_reconsent',
expires_at: '2026-08-19T10:00:00Z',
refresh_token: 'ciphertext',
refresh_count: 1,
last_error_at: '2026-08-18T03:00:00Z',
},
})
const notice = await detectSkvDisconnected(supabase, USER, COMPANY, NOW)
expect(notice).toMatchObject({
id: 'skv_disconnected:needs_reconsent@2026-08-18T03:00:00Z',
category: 'skv_disconnected',
severity: 'error',
actionHref: '/settings/tax',
})
expect(findCall('skatteverket_tokens', 'eq')).toEqual(['user_id', USER])
})
it('fires on an expired token with no refresh token left', async () => {
enqueue({
data: {
status: 'active',
expires_at: '2026-08-19T10:00:00Z',
refresh_token: null,
refresh_count: 0,
last_error_at: null,
},
})
const notice = await detectSkvDisconnected(supabase, USER, COMPANY, NOW)
expect(notice).toMatchObject({ id: 'skv_disconnected:expired@2026-08-19T10:00:00Z' })
})
it('fires on an expired token whose refresh budget is exhausted', async () => {
enqueue({
data: {
status: 'active',
expires_at: '2026-08-19T10:00:00Z',
refresh_token: 'ciphertext',
refresh_count: 10,
last_error_at: null,
},
})
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toMatchObject({
category: 'skv_disconnected',
})
})
it('stays quiet while the token is expired but still refreshable', async () => {
enqueue({
data: {
status: 'active',
expires_at: '2026-08-19T10:00:00Z',
refresh_token: 'ciphertext',
refresh_count: 3,
last_error_at: null,
},
})
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
})
it('stays quiet on a healthy, unexpired token', async () => {
enqueue({
data: {
status: 'active',
expires_at: '2026-08-19T14:00:00Z',
refresh_token: 'ciphertext',
refresh_count: 0,
last_error_at: null,
},
})
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
})
it('soft-fails to null on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
})
})
describe('detectBackupFailing', () => {
it('returns null when nothing is connected', async () => {
enqueue({ data: [] })
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
})
it('returns null when connected backups are healthy', async () => {
enqueue({
data: [
{ key: 'google_drive_connection', value: { status: 'active' } },
{ key: 'google_drive_schedule', value: { last_auto_sync_status: 'success' } },
],
})
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
})
it('fires the reauth message when every failing provider needs reauth', async () => {
enqueue({
data: [
{
key: 'google_drive_connection',
value: { status: 'needs_reauth', needs_reauth_at: '2026-08-17T00:00:00Z' },
},
],
})
const notice = await detectBackupFailing(supabase, COMPANY)
expect(notice).toMatchObject({
id: 'backup_failing:google_drive=reauth',
category: 'backup_failing',
severity: 'error',
messageKey: 'backup_reauth',
messageParams: { provider: 'Google Drive' },
actionHref: '/import#cloud-backup',
})
})
it('keeps the id stable while the cron re-stamps last_auto_sync_at on the SAME incident', async () => {
const failingRun = (stampedAt: string) => [
{ key: 'google_drive_connection', value: { status: 'active' } },
{
key: 'google_drive_schedule',
value: { last_auto_sync_status: 'error', last_auto_sync_at: stampedAt },
},
]
enqueue({ data: failingRun('2026-08-18T02:00:00Z') })
enqueue({ data: failingRun('2026-08-19T02:00:00Z') })
const first = await detectBackupFailing(supabase, COMPANY)
const second = await detectBackupFailing(supabase, COMPANY)
expect(first?.id).toBe('backup_failing:google_drive=sync_error')
expect(second?.id).toBe(first?.id)
})
it('folds two failing providers into ONE notice with both names and a sorted id', async () => {
enqueue({
data: [
{ key: 'google_drive_connection', value: { status: 'needs_reauth' } },
{ key: 'dropbox_connection', value: { status: 'active' } },
{
key: 'dropbox_schedule',
value: { last_auto_sync_status: 'error', last_auto_sync_at: '2026-08-18T02:00:00Z' },
},
],
})
const notice = await detectBackupFailing(supabase, COMPANY)
expect(notice).toMatchObject({
id: 'backup_failing:dropbox=sync_error,google_drive=reauth',
messageKey: 'backup_failing',
messageParams: { provider: 'Google Drive + Dropbox' },
})
})
it('ignores an errored schedule for a provider without a connection', async () => {
enqueue({
data: [{ key: 'dropbox_schedule', value: { last_auto_sync_status: 'error' } }],
})
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
})
it('soft-fails to null on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
})
})
describe('detectOtherAccountHint', () => {
it('shapes the hint as the lowest-priority notice when the detector fires', async () => {
otherAccountHintMock.mockResolvedValue(true)
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toMatchObject({
id: 'other_account_hint',
category: 'other_account_hint',
severity: 'warning',
})
})
it('returns null when the detector stays quiet', async () => {
otherAccountHintMock.mockResolvedValue(false)
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toBeNull()
})
it('soft-fails to null when the detector throws', async () => {
otherAccountHintMock.mockRejectedValue(new Error('boom'))
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toBeNull()
})
})
describe('never-throws contract', () => {
it('every predicate resolves null when the client itself throws', async () => {
const throwing = {
from: () => {
throw new Error('client exploded')
},
} as unknown as SupabaseClient
otherAccountHintMock.mockRejectedValue(new Error('client exploded'))
await expect(detectBrokenBankConnections(throwing, COMPANY)).resolves.toBeNull()
await expect(detectExpiringBankConnections(throwing, COMPANY, NOW)).resolves.toBeNull()
await expect(detectSkvDisconnected(throwing, USER, COMPANY, NOW)).resolves.toBeNull()
await expect(detectBackupFailing(throwing, COMPANY)).resolves.toBeNull()
await expect(detectOtherAccountHint(throwing, COMPANY)).resolves.toBeNull()
})
})
+127
View File
@@ -0,0 +1,127 @@
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()
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Per-category notice predicates: the single owner of every degraded-state
* detection. Surfaces (Hem, /api/notices, per-page attn lines) must call
* these, or the exported pure helpers, instead of inlining their own checks;
* see lib/notices/types.ts for each category's pending/done definition.
*
* Every predicate soft-fails to null with a logged error: a broken health
* check must never take down the dashboard or the home page.
*/
import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
import { expiringBankConnectionsFrom, skvStatusNeedsReconnect } from './predicates'
import type { Notice } from './types'
// The pure decision layer lives in ./predicates (client-safe: 'use client'
// pages import it directly, since this file pulls in server-only modules).
// Re-exported here so server code has one import path for the whole domain.
export * from './predicates'
const log = createLogger('notices')
function logAndNull(
category: string,
companyId: string,
error: { message?: string } | null,
): null {
// companyId is a structured field so repeated failures can be correlated
// to a tenant in monitoring (mirrors lib/worklist logAndZero).
log.error(`notice predicate failed: ${category}`, { companyId, reason: error?.message })
return null
}
/**
* Bound a multi-part id discriminator. A single part stays human-readable
* (a connection id plus its status/expiry); several parts, each embedding a
* uuid, collapse to `<count>@<first 8 hex of sha256 over the sorted parts>`,
* so the id stays far below the dismiss schema cap (lib/api/schemas.ts) no
* matter how many connections fold into one notice, and is stable across
* row orderings. Server-only (node:crypto), which this module already is.
*/
function boundedDiscriminator(parts: string[]): string {
if (parts.length === 1) return parts[0]
const digest = createHash('sha256')
.update([...parts].sort().join(','))
.digest('hex')
.slice(0, 8)
return `${parts.length}@${digest}`
}
// ── Predicates (one query each; soft-fail to null) ──
/**
* bank_connection_broken: connections whose status is already terminal
* ('expired' | 'error'): same predicate as BankSyncStatusChip's "attention"
* state. Disjoint from bank_connection_expiring by the status filter.
*/
export async function detectBrokenBankConnections(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, status, bank_name')
.eq('company_id', companyId)
.in('status', ['expired', 'error'])
if (error) return logAndNull('bank_connection_broken', companyId, error)
const rows = data ?? []
if (rows.length === 0) return null
const discriminator = boundedDiscriminator(rows.map((r) => `${r.id}=${r.status}`))
// A NULL bank_name switches to the unnamed message variant instead of
// interpolating a fallback word, which would leak Swedish into English.
const bankName = rows.length === 1 ? ((rows[0].bank_name as string | null) || null) : null
return {
id: `bank_connection_broken:${discriminator}`,
category: 'bank_connection_broken',
severity: 'error',
messageKey:
rows.length === 1
? bankName
? 'bank_broken_one'
: 'bank_broken_one_unnamed'
: 'bank_broken_many',
messageParams:
rows.length === 1
? bankName
? { bank: bankName }
: undefined
: { count: rows.length },
actionKey: 'bank_broken_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_broken',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* bank_connection_expiring: active connections whose PSD2 consent runs out
* within 14 days. Only status = 'active' rows are considered, so a
* connection that has ALREADY failed never shows as both broken and
* expiring (broken supersedes expiring for the same connection).
*/
export async function detectExpiringBankConnections(
supabase: SupabaseClient,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
const { data, error } = await supabase
.from('bank_connections')
.select('id, bank_name, consent_expires')
.eq('company_id', companyId)
.eq('status', 'active')
.not('consent_expires', 'is', null)
if (error) return logAndNull('bank_connection_expiring', companyId, error)
const expiring = expiringBankConnectionsFrom(data ?? [], now)
if (expiring.length === 0) return null
// The consent date, not days_left, discriminates the id: the countdown
// ticking from 14 to 13 days must not resurrect a dismissed notice.
const consentByid = new Map(
(data ?? []).map((r) => [r.id as string, r.consent_expires as string | null]),
)
const discriminator = boundedDiscriminator(
expiring.map((c) => `${c.id}=${consentByid.get(c.id) ?? ''}`),
)
return {
id: `bank_connection_expiring:${discriminator}`,
category: 'bank_connection_expiring',
severity: 'warning',
messageKey: expiring.length === 1 ? 'bank_expiring_one' : 'bank_expiring_many',
messageParams:
expiring.length === 1
? { bank: expiring[0].bank_name, days: expiring[0].days_left }
: { count: expiring.length },
actionKey: 'bank_expiring_action',
actionHref: '/settings/banking',
}
} catch (err) {
return logAndNull(
'bank_connection_expiring',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* skv_disconnected: a stored Skatteverket connection that can no longer
* authenticate. Mirrors the skatteverket extension's /status route exactly
* (needs_reconsent flag, or expired with no usable refresh token) and runs
* the shared skvStatusNeedsReconnect decision over the row. Connections are
* per (user, company), so the predicate needs the caller's user id.
* Refresh-token ciphertext is read only for a null check and never returned.
*/
export async function detectSkvDisconnected(
supabase: SupabaseClient,
userId: string,
companyId: string,
now: Date = new Date(),
): Promise<Notice | null> {
try {
if ((process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true') return null
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('status, expires_at, refresh_token, refresh_count, last_error_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.maybeSingle()
if (error) return logAndNull('skv_disconnected', companyId, error)
if (!data) return null
const status = (data.status as string | null) ?? 'active'
const expiresAt = data.expires_at as string | null
const expired = expiresAt !== null && new Date(expiresAt).getTime() < now.getTime()
const canRefresh = data.refresh_token !== null && ((data.refresh_count as number | null) ?? 0) < 10
const needsReconsent = status === 'needs_reconsent'
if (!skvStatusNeedsReconnect({ connected: true, needsReconsent, expired, canRefresh })) {
return null
}
// needs_reconsent rows discriminate on when the terminal error was
// detected; refresh-exhausted rows on when the token expired: either way
// a NEW failure after a successful re-consent mints a new id.
const discriminator = needsReconsent
? `needs_reconsent@${(data.last_error_at as string | null) ?? ''}`
: `expired@${expiresAt ?? ''}`
return {
id: `skv_disconnected:${discriminator}`,
category: 'skv_disconnected',
severity: 'error',
messageKey: 'skv_disconnected',
actionKey: 'skv_disconnected_action',
actionHref: '/settings/tax',
}
} catch (err) {
return logAndNull(
'skv_disconnected',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/** Brand names stay untranslated; the sentence around them is localised. */
const BACKUP_PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox',
}
interface BackupConnectionValue {
status?: 'active' | 'needs_reauth'
needs_reauth_at?: string
}
interface BackupScheduleValue {
last_auto_sync_status?: 'success' | 'error' | null
last_auto_sync_at?: string | null
}
/**
* backup_failing: a connected cloud-backup destination with a dead token or
* an errored last auto-sync. Reads the cloud-backup extension's rows in
* extension_data directly (core must not import from @/extensions/, so the
* keys and value shapes are mirrored here, same as the old
* BackupHealthBanner mirrored the status API's shape). Multiple failing
* providers fold into ONE notice: a working Drive backup does not make a
* broken Dropbox backup acceptable, but two failures must not stack two
* lines either.
*/
export async function detectBackupFailing(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return null
const { data, error } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', companyId)
.eq('extension_id', 'cloud-backup')
.in('key', [
'google_drive_connection',
'google_drive_schedule',
'dropbox_connection',
'dropbox_schedule',
])
if (error) return logAndNull('backup_failing', companyId, error)
const byKey = new Map((data ?? []).map((r) => [r.key as string, r.value]))
const failing: { provider: string; reason: 'reauth' | 'sync_error' }[] = []
for (const provider of ['google_drive', 'dropbox']) {
const connection = byKey.get(`${provider}_connection`) as BackupConnectionValue | undefined
if (!connection) continue
const schedule = byKey.get(`${provider}_schedule`) as BackupScheduleValue | undefined
if (connection.status === 'needs_reauth') {
failing.push({ provider, reason: 'reauth' })
} else if (schedule?.last_auto_sync_status === 'error') {
failing.push({ provider, reason: 'sync_error' })
}
}
if (failing.length === 0) return null
const names = failing
.map((f) => BACKUP_PROVIDER_LABELS[f.provider] ?? f.provider)
.join(' + ')
const allNeedReauth = failing.every((f) => f.reason === 'reauth')
// Deliberately NO timestamp in the discriminator: the cron re-stamps
// last_auto_sync_at on every failed run (and can re-stamp needs_reauth_at
// on retries), which would resurrect a dismissed notice daily while the
// SAME incident persists. The id is stable per (provider, reason) and the
// opposite direction (a NEW failure after a healthy spell must resurface)
// is guaranteed by the stale-dismissal reaping in aggregate.ts; contract
// in lib/notices/types.ts.
const discriminator = failing
.map((f) => `${f.provider}=${f.reason}`)
.sort()
.join(',')
return {
id: `backup_failing:${discriminator}`,
category: 'backup_failing',
severity: 'error',
messageKey: allNeedReauth ? 'backup_reauth' : 'backup_failing',
messageParams: { provider: names },
actionKey: 'backup_action',
actionHref: '/import#cloud-backup',
}
} catch (err) {
return logAndNull(
'backup_failing',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
/**
* other_account_hint: the wrong-login nudge (#1231). Delegates the detection
* to lib/company/other-account-hint (which already fails soft to false); this
* wrapper only shapes it as the lowest-priority notice. The id carries no
* state discriminator: the condition is "this account is empty while another
* holds the bookkeeping", which either holds or stops holding.
*/
export async function detectOtherAccountHint(
supabase: SupabaseClient,
companyId: string,
): Promise<Notice | null> {
try {
const show = await shouldShowOtherAccountHint(supabase)
if (!show) return null
return {
id: 'other_account_hint',
category: 'other_account_hint',
severity: 'warning',
messageKey: 'other_account_hint',
actionKey: 'other_account_hint_action',
// Client surfaces override this with a sign-out handler; the href is
// the no-JS fallback destination.
actionHref: '/login',
}
} catch (err) {
return logAndNull(
'other_account_hint',
companyId,
err instanceof Error ? { message: err.message } : null,
)
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './types'
export * from './categories'
export * from './aggregate'
+71
View File
@@ -0,0 +1,71 @@
/**
* 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'
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 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
}
+1
View File
@@ -1044,6 +1044,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
'mailbox OAuth grants (live refresh tokens), not portable. The receipts they find are archived as documents.',
mcp_tasks: 'MCP task handles: transient tool-call state with a 1-hour TTL',
metered_events: 'billing telemetry',
notice_dismissals: 'per-user UI notice dismissal state, not räkenskapsinformation',
notification_log: 'notification dedup log',
operations: 'staged-operation workflow state',
payment_match_log: 'derived matching log',