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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
4cf227001d
commit
9fc05c383f
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './categories'
|
||||
export * from './aggregate'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user