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
parent 4cf227001d
commit 9fc05c383f
26 changed files with 1921 additions and 173 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ Decided during the 2026-07 concept work (dev_docs/ui_migration_plan.md); they ap
3. **Buttons are pills.** Radius 99px, default padding 7px 16px, 13px text. Set once in `components/ui/button.tsx`, app-wide, never per page.
4. **Table rows are one line.** Secondary info (descriptions, OCR, roles) belongs in the detail view or a click-popup, never as sub-rows in lists.
5. **Chips mark exceptions.** Normal states render as muted text; Badge only when the row deviates. Same chip on every row means the chip is wrong.
6. **Attention is one ochre sentence**, not a banner: the `.attn` pattern (12.5px, `--warning` tone, single line, optionally with an embedded action link). Max one per page.
6. **Attention is one ochre sentence**, not a banner: the `.attn` pattern (12.5px, `--warning` tone, single line, optionally with an embedded action link). Max one per page. Addendum 2026-08-19: a page may show at most one global notice line sourced from `lib/notices` (highest priority wins, additional active notices collapse behind a quiet "+N till" inline expander) plus at most one page-domain attn line.
7. **Help text lives behind a "?"** right after the H1: a small (17px) circular button opening a popover anchored at the button. No instructional copy in the page flow.
8. **One context picker per page, far right in the toolbar**: fiscal year or account/source as a chip-dropdown with a check on the active choice. A chip that looks like a picker must be a picker.
9. **The primary action lives in the page header**, right side. Multiple create paths collapse into a split button whose caret menu remembers the last-used mode (persisted in `user_preferences`, not localStorage).
+3
View File
@@ -1096,3 +1096,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-19] Skattekonto sync datumFrom bound applied uniformly to EF and AB (earliest fiscal_periods.period_start, no bound when none exists): an AB's skattekonto is the company's own so the bound is a no-op there, and one code path beats an entity_type branch; the EF case (owner's personal account, ~555 days of private pre-company history) is the one that needed it.
[2026-08-19] company_settings.employer_registered chosen as the "does this EF employ anyone" signal for the avdragen-skatt gate (not an employees-table query): it is the existing AGI gate signal (20260717151000, attested pays_salaries OR any salary_run), already maintained for exactly this question, and one boolean on the settings row the rule context already fetches costs no extra round trip.
[2026-08-19] requires_employer added as a data-driven skattekonto_rules column (set on the 'avdragen skatt,personalskatt,a-skatt' seed) instead of a hardcoded pattern match in TypeScript: the table exists precisely so rule semantics ship without redeploys, and a string match in code would silently miss per-company clones of the seed pattern.
[2026-08-19] Notices Option A (contextual AttnLine slot fed by lib/notices) over a global layout notice strip: reuses convention 6 instead of adding a second piece of persistent chrome that could itself stack; Option B stays a founder call.
[2026-08-19] notice_dismissals is server-side per (company, user, notice_id): dismissals must work cross-device and stay personal (a colleague still sees the notice), unlike the localStorage pattern SkatteverketPromoCard uses (left as-is here, follow-up).
[2026-08-19] Notice ids embed a state discriminator (connection id + status/expiry/error timestamp) instead of a snooze timestamp: a dismissal hides exactly the state the user saw, and a NEW failure mints a new id that surfaces without any clock logic.
+12 -23
View File
@@ -2,7 +2,8 @@ import { redirect } from 'next/navigation'
import DashboardContent from '@/components/dashboard/DashboardContent'
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
import { listResumeItems } from '@/lib/worklist/resume'
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
import { getCompanyNotices } from '@/lib/notices'
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
import type { OnboardingProgress } from '@/types'
import {
@@ -53,7 +54,7 @@ export default async function DashboardPage() {
worklist,
suggestedMatches,
resumeItems,
otherAccountHint,
notices,
] = await Promise.all([
getDashboardSettings(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
@@ -95,10 +96,10 @@ export default async function DashboardPage() {
listSuggestedMatches(supabase, companyId, 5),
// In-progress work for the Fortsätt pane: pure draft-state derivation.
listResumeItems(supabase, companyId, now),
// Wrong-account hint (#1231): true only when this account has zero
// journal entries while a same-orgnr company with real bookkeeping
// exists in another account. Common case costs one existence probe.
shouldShowOtherAccountHint(supabase),
// Degraded-state notices (lib/notices): broken/expiring bank
// connections, Skatteverket reconnect, failing backups, and the
// wrong-account hint (#1231), priority-ordered and dismissal-filtered.
getCompanyNotices(supabase, companyId, { userId: user.id, now }),
])
// A FAILED settings read must not masquerade as "onboarding not done":
@@ -139,22 +140,10 @@ export default async function DashboardPage() {
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
const nowMs = now.getTime()
const expiringBankConnections = (bankConnections || [])
.filter(conn => {
if (!conn.consent_expires) return false
const daysLeft = Math.ceil(
(new Date(conn.consent_expires).getTime() - nowMs) / (1000 * 60 * 60 * 24)
)
return daysLeft > 0 && daysLeft <= 14
})
.map(conn => ({
id: conn.id as string,
bank_name: conn.bank_name as string,
days_left: Math.ceil(
(new Date(conn.consent_expires!).getTime() - nowMs) / (1000 * 60 * 60 * 24)
),
}))
// Same day-math as the bank_connection_expiring notice predicate
// (lib/notices/categories.ts): the Bevaka row and the notice can never
// disagree on the threshold.
const expiringBankConnections = expiringBankConnectionsFrom(bankConnections || [], now)
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
@@ -198,7 +187,7 @@ export default async function DashboardPage() {
worklist={worklist}
suggestedMatches={suggestedMatches}
resumeItems={resumeItems}
otherAccountHint={otherAccountHint}
notices={notices}
onboardingProgress={onboardingProgress}
initialSetup={{
path: settings.initial_setup_path ?? null,
+6 -4
View File
@@ -44,6 +44,7 @@ import {
} from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
import { skvAuthErrorNeedsReconnect } from '@/lib/notices/predicates'
import {
AlertCircle,
Copy,
@@ -212,16 +213,17 @@ export default function SkattekontoPage() {
// …) fire while Inställningar truthfully shows the stored token as
// "Ansluten". Showing the full "inte anslutet"-tomvy for those
// contradicts the settings panel; show the server's actual reason
// with a reconnect CTA instead.
// with a reconnect CTA instead. The split lives in the shared
// skvAuthErrorNeedsReconnect predicate (lib/notices), never inline.
if (res.status === 401) {
if (json.code === 'NOT_CONNECTED') {
setNotConnected(true)
} else {
if (skvAuthErrorNeedsReconnect(res.status, json.code)) {
setReconnectMessage(
typeof json.error === 'string' && json.error
? json.error
: 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.',
)
} else {
setNotConnected(true)
}
return
}
+5 -14
View File
@@ -61,6 +61,7 @@ import type {
} from '@/types/skatteverket'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { skvStatusNeedsReconnect, type SkvStatusLike } from '@/lib/notices/predicates'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
@@ -914,21 +915,11 @@ export default function TransactionsPage() {
setSkvNeedsReconnect(false)
return
}
const s = (await res.json()) as {
connected?: boolean
disabled?: boolean
needsReconsent?: boolean
expired?: boolean
canRefresh?: boolean
}
const s = (await res.json()) as SkvStatusLike
if (skvFetchSeqRef.current !== seq) return
setSkvNeedsReconnect(
Boolean(
s.connected &&
!s.disabled &&
(s.needsReconsent || (s.expired && !s.canRefresh)),
),
)
// Shared reconnect predicate (lib/notices): the same decision the
// skattekonto page and the Hem notice make, never a local variant.
setSkvNeedsReconnect(skvStatusNeedsReconnect(s))
} catch {
if (skvFetchSeqRef.current !== seq) return
setSkvNeedsReconnect(false)
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
import type { Notice } from '@/lib/notices/types'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
const getCompanyNoticesMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/notices', () => ({
getCompanyNotices: getCompanyNoticesMock,
}))
import { GET } from '../route'
const notice: Notice = {
id: 'skv_disconnected:needs_reconsent@2026-08-18T03:00:00Z',
category: 'skv_disconnected',
severity: 'error',
messageKey: 'skv_disconnected',
actionKey: 'skv_disconnected_action',
actionHref: '/settings/tax',
}
describe('GET /api/notices', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
getCompanyNoticesMock.mockResolvedValue([notice])
})
it('returns 401 when the user is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(createMockRequest('/api/notices'), { params: Promise.resolve({}) })
expect(response.status).toBe(401)
expect(getCompanyNoticesMock).not.toHaveBeenCalled()
})
it('returns the ordered notices for the active company and calling user', async () => {
const { status, body } = await parseJsonResponse<{ data: { notices: Notice[] } }>(
await GET(createMockRequest('/api/notices'), { params: Promise.resolve({}) }),
)
expect(status).toBe(200)
expect(body.data.notices).toEqual([notice])
expect(getCompanyNoticesMock).toHaveBeenCalledWith(supabase, 'company-1', {
userId: 'user-1',
})
})
})
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { POST } from '../route'
describe('POST /api/notices/dismiss', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
})
it('returns 401 when the user is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/notices/dismiss', {
method: 'POST',
body: { notice_id: 'skv_disconnected:x' },
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(401)
})
it('returns 400 when notice_id is missing or empty', async () => {
const missing = await POST(
createMockRequest('/api/notices/dismiss', { method: 'POST', body: {} }),
{ params: Promise.resolve({}) },
)
expect(missing.status).toBe(400)
const empty = await POST(
createMockRequest('/api/notices/dismiss', { method: 'POST', body: { notice_id: '' } }),
{ params: Promise.resolve({}) },
)
expect(empty.status).toBe(400)
})
it('upserts the dismissal scoped to the calling user and company', async () => {
enqueue({ data: null, error: null })
const { status, body } = await parseJsonResponse<{ data: { dismissed: boolean } }>(
await POST(
createMockRequest('/api/notices/dismiss', {
method: 'POST',
body: { notice_id: 'bank_connection_broken:c1=expired' },
}),
{ params: Promise.resolve({}) },
),
)
expect(status).toBe(200)
expect(body.data.dismissed).toBe(true)
const upsertArgs = findCall('notice_dismissals', 'upsert')
expect(upsertArgs?.[0]).toMatchObject({
company_id: 'company-1',
user_id: 'user-1',
notice_id: 'bank_connection_broken:c1=expired',
})
expect(upsertArgs?.[1]).toEqual({ onConflict: 'company_id,user_id,notice_id' })
})
it('maps a failed write to a 500 error envelope', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const response = await POST(
createMockRequest('/api/notices/dismiss', {
method: 'POST',
body: { notice_id: 'x' },
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(500)
})
})
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { NoticeDismissSchema } from '@/lib/api/schemas'
/**
* POST /api/notices/dismiss: hide one notice for the calling user + company.
*
* Dismissals are per user (a colleague still sees the notice) and per notice
* id; ids embed a state discriminator (lib/notices/types.ts), so a dismissed
* notice stays hidden only until the underlying condition changes: a NEW
* failure mints a new id and surfaces again. Idempotent via upsert.
*
* No requireWrite: dismissing a personal notice is a per-user preference,
* not a company-data mutation, so viewers may dismiss too.
*/
export const POST = withRouteContext('notices.dismiss', async (request, ctx) => {
const validation = await validateBody(request, NoticeDismissSchema)
if (!validation.success) return validation.response
const { supabase, companyId, user } = ctx
const { error } = await supabase.from('notice_dismissals').upsert(
{
company_id: companyId,
user_id: user.id,
notice_id: validation.data.notice_id,
dismissed_at: new Date().toISOString(),
},
{ onConflict: 'company_id,user_id,notice_id' },
)
if (error) {
throw new Error(`notice dismissal failed: ${error.message}`)
}
return NextResponse.json({ data: { dismissed: true } })
})
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getCompanyNotices } from '@/lib/notices'
/**
* GET /api/notices: active degraded-state notices for the active company,
* in priority order (lib/notices, the health sibling of /api/worklist/counts).
*
* Read-only; every predicate is one bounded query that soft-fails to null,
* and per-user dismissals are already filtered out. No events are emitted,
* so ensureInitialized() is deliberately absent.
*
* Response: { data: { notices: Notice[] } }
*/
export const GET = withRouteContext('notices.list', async (_request, ctx) => {
const { supabase, companyId, user } = ctx
const notices = await getCompanyNotices(supabase, companyId, { userId: user.id })
return NextResponse.json({ data: { notices } })
})
-104
View File
@@ -1,104 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { AlertTriangle } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
// Local mirror of the cloud-backup status shape: core must not import from
// @/extensions/, so the fields we read are declared here.
interface BackupProviderStatus {
provider: string
connected: boolean
needs_reauth: boolean
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
}
interface BackupStatus {
providers?: BackupProviderStatus[]
// Pre-multi-provider shape, describing Google Drive alone.
connected: boolean
needs_reauth: boolean
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
}
/** Brand names stay untranslated; the sentence around them is localised. */
const PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox',
}
/**
* Warning shown on the dashboard ONLY when a connected cloud backup is failing
* (dead token or errored auto-sync). A backup that silently stops is worse
* than none; this makes the failure visible where the user actually is.
* Renders nothing when the extension is off, disconnected, or healthy.
*
* With more than one destination connected, a failure on either one surfaces:
* a working Drive backup does not make a broken Dropbox backup acceptable.
*/
export default function BackupHealthBanner() {
const t = useTranslations('extensions')
const [status, setStatus] = useState<BackupStatus | null>(null)
useEffect(() => {
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return
let cancelled = false
fetch('/api/extensions/ext/cloud-backup/status')
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (!cancelled && body?.data) setStatus(body.data as BackupStatus)
})
.catch(() => {
// Fail silent: the dashboard must not degrade over a status probe.
})
return () => {
cancelled = true
}
}, [])
if (!status) return null
const providers: BackupProviderStatus[] = status.providers ?? [
{
provider: 'google_drive',
connected: status.connected,
needs_reauth: status.needs_reauth,
schedule: status.schedule,
},
]
const failing = providers.filter(
(p) =>
p.connected &&
(p.needs_reauth || p.schedule?.last_auto_sync_status === 'error')
)
if (failing.length === 0) return null
// One sentence covering everything that is broken, so two dead connections
// do not stack two banners on the dashboard.
const names = failing
.map((p) => PROVIDER_LABELS[p.provider] ?? p.provider)
.join(' + ')
const allNeedReauth = failing.every((p) => p.needs_reauth)
return (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div className="flex-1 text-sm">
<p className="font-medium">
{allNeedReauth
? t('ext_cloud_backup_banner_reauth', { provider: names })
: t('ext_cloud_backup_banner_failing', { provider: names })}
</p>
<Link
href="/import#cloud-backup"
className="mt-1 inline-block text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
{t('ext_cloud_backup_banner_action')}
</Link>
</div>
</div>
)
}
+17 -16
View File
@@ -4,15 +4,15 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { AttnLine } from '@/components/ui/attn-line'
import { useCompany } from '@/contexts/CompanyContext'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import AttGoraSection from '@/components/dashboard/AttGoraSection'
import ResumePane from '@/components/dashboard/ResumePane'
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
import NoticeLines from '@/components/dashboard/NoticeLines'
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
import { AgentPromo } from '@/components/dashboard/AgentPromo'
import type { InitialSetupState, OnboardingProgress } from '@/types'
import type { Notice } from '@/lib/notices/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import type { ResumeItem } from '@/lib/worklist/resume'
import type { VatDeadlineLine } from '@/lib/onboarding/checklist'
@@ -31,11 +31,12 @@ interface DashboardContentProps {
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
resumeItems: ResumeItem[]
/**
* True when this account looks bookkeeping-empty while a same-orgnr
* company with real bookkeeping exists in another account (#1231): the
* user probably signed in with the wrong login (stale BankID account).
* Active degraded-state notices in priority order (lib/notices): broken or
* expiring bank connections, Skatteverket reconnect, failing backups, the
* wrong-account hint. Rendered as ONE attn line at the top with a quiet
* "+N till" expander: never a stack of banners.
*/
otherAccountHint?: boolean
notices?: Notice[]
onboardingProgress?: OnboardingProgress
initialSetup: InitialSetupState
/**
@@ -72,7 +73,7 @@ export default function DashboardContent({
worklist,
suggestedMatches,
resumeItems,
otherAccountHint = false,
notices = [],
onboardingProgress,
initialSetup,
agentBuilt = true,
@@ -107,7 +108,15 @@ export default function DashboardContent({
return (
<div className="stagger-enter space-y-8">
<BackupHealthBanner />
{/* Degraded-state notices (lib/notices): one attn line, highest
priority first, quiet "+N till" expander. The wrong-account hint
participates in the same priority list instead of rendering its own
unconditional line, and the old boxed BackupHealthBanner card lives
on as the backup_failing category. */}
<NoticeLines
notices={notices}
actionOverrides={{ other_account_hint: handleSwitchAccount }}
/>
{/* Greeting hero (concept scene 14) */}
<section>
@@ -118,14 +127,6 @@ export default function DashboardContent({
{dateLine}
{company?.name ? ` · ${company.name}` : ''}
</p>
{otherAccountHint && (
<AttnLine
className="mt-3"
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
>
{t('other_account_hint')}
</AttnLine>
)}
</section>
<NewUserChecklist
+90
View File
@@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { AttnLine } from '@/components/ui/attn-line'
import type { Notice, NoticeCategory } from '@/lib/notices/types'
interface NoticeLinesProps {
/** Active notices in priority order (lib/notices getCompanyNotices). */
notices: Notice[]
/**
* Per-category client-side action overrides: some notices need a handler
* rather than a link (other_account_hint signs the user out).
*/
actionOverrides?: Partial<Record<NoticeCategory, () => void>>
}
/**
* The Hem notice slot: renders ONLY the single highest-priority active
* notice as one AttnLine (convention 6), with a quiet "+N till" text link
* expanding the rest inline when more are active. No boxed card, no overlay.
*
* Dismiss is optimistic and per user (POST /api/notices/dismiss): a failed
* write just lets the notice reappear on the next load, which is the safe
* direction for a health warning.
*/
export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLinesProps) {
const t = useTranslations('notices')
const [expanded, setExpanded] = useState(false)
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set())
const active = notices.filter((n) => !hiddenIds.has(n.id))
if (active.length === 0) return null
const visible = expanded ? active : [active[0]]
const moreCount = active.length - 1
function dismiss(notice: Notice) {
setHiddenIds((prev) => new Set(prev).add(notice.id))
// Fire-and-forget on purpose: on failure the notice simply returns on
// the next load. Never block or error the dashboard over a dismissal.
void fetch('/api/notices/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notice_id: notice.id }),
}).catch(() => {})
}
return (
<div className="space-y-1">
{visible.map((notice, index) => {
const override = actionOverrides[notice.category]
return (
<AttnLine
key={notice.id}
action={
override
? { label: t(notice.actionKey), onClick: override }
: { label: t(notice.actionKey), href: notice.actionHref }
}
trailing={
<>
{index === 0 && !expanded && moreCount > 0 && (
<>
{' '}
<button
type="button"
onClick={() => setExpanded(true)}
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t('more_count', { count: moreCount })}
</button>
</>
)}{' '}
<button
type="button"
onClick={() => dismiss(notice)}
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t('dismiss')}
</button>
</>
}
>
{t(notice.messageKey, notice.messageParams)}
</AttnLine>
)
})}
</div>
)
}
+8 -1
View File
@@ -5,6 +5,12 @@ interface AttnLineProps {
children: React.ReactNode
/** Optional inline action at the end of the sentence. */
action?: { label: string; href?: string; onClick?: () => void }
/**
* Optional quiet controls rendered inside the sentence after the action
* (e.g. the notice line's "+N till" expander and dismiss). Style them
* muted; the ochre tone belongs to the sentence and its action alone.
*/
trailing?: React.ReactNode
className?: string
}
@@ -13,7 +19,7 @@ interface AttnLineProps {
* a single 12.5px line in the attn tone with an optional embedded action
* link. Max one per page.
*/
export function AttnLine({ children, action, className }: AttnLineProps) {
export function AttnLine({ children, action, trailing, className }: AttnLineProps) {
return (
<p className={cn('text-[12.5px] leading-5 text-attn', className)}>
{children}
@@ -38,6 +44,7 @@ export function AttnLine({ children, action, className }: AttnLineProps) {
)}
</>
)}
{trailing}
</p>
)
}
+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',
+18 -5
View File
@@ -5497,9 +5497,6 @@
"ext_cloud_backup_auto_sync_success": "succeeded",
"ext_cloud_backup_auto_sync_error": "failed",
"ext_cloud_backup_schedule_save_failed": "Could not save schedule",
"ext_cloud_backup_banner_reauth": "The {provider} backup is paused: the account needs to be reconnected.",
"ext_cloud_backup_banner_failing": "The automatic {provider} backup is failing.",
"ext_cloud_backup_banner_action": "Open backup settings",
"ext_cloud_backup_verified": "Verified",
"ext_cloud_backup_up_to_date": "Already up to date",
"ext_cloud_backup_no_changes": "No changes since the last sync.",
@@ -6402,6 +6399,24 @@
"status_paid_date": "Paid {date}",
"status_picker_aria": "Filter by status"
},
"notices": {
"bank_broken_one": "The {bank} bank connection has stopped working and no longer fetches transactions.",
"bank_broken_one_unnamed": "A bank connection has stopped working and no longer fetches transactions.",
"bank_broken_many": "{count} bank connections have stopped working and no longer fetch transactions.",
"bank_broken_action": "Renew the connection",
"skv_disconnected": "The Skatteverket connection needs to be renewed. Reconnect with BankID.",
"skv_disconnected_action": "Reconnect",
"backup_reauth": "The {provider} backup is paused: the account needs to be reconnected.",
"backup_failing": "The automatic {provider} backup is failing.",
"backup_action": "Open backup settings",
"bank_expiring_one": "The bank consent for {bank} expires in {days, plural, =1 {1 day} other {# days}}.",
"bank_expiring_many": "{count} bank consents expire soon.",
"bank_expiring_action": "Renew the consent",
"other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?",
"other_account_hint_action": "Switch account",
"more_count": "+{count} more",
"dismiss": "Hide"
},
"dashboard": {
"skv_promo_title": "Connect Skatteverket",
"skv_promo_description": "See your tax account and file VAT and employer declarations directly from here. Connect with BankID in a couple of minutes.",
@@ -6471,8 +6486,6 @@
"greeting_morning": "Good morning",
"greeting_day": "Good day",
"greeting_evening": "Good evening",
"other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?",
"other_account_hint_action": "Switch account",
"resume_title": "Continue",
"resume_invoice_draft": "Draft: invoice to {customer}",
"resume_invoice_unsent": "Send invoice {number}",
+18 -5
View File
@@ -5497,9 +5497,6 @@
"ext_cloud_backup_auto_sync_success": "lyckades",
"ext_cloud_backup_auto_sync_error": "misslyckades",
"ext_cloud_backup_schedule_save_failed": "Kunde inte spara schema",
"ext_cloud_backup_banner_reauth": "Säkerhetskopieringen till {provider} är pausad: kontot behöver kopplas om.",
"ext_cloud_backup_banner_failing": "Den automatiska säkerhetskopieringen till {provider} misslyckas.",
"ext_cloud_backup_banner_action": "Öppna säkerhetskopiering",
"ext_cloud_backup_verified": "Verifierad",
"ext_cloud_backup_up_to_date": "Redan uppdaterad",
"ext_cloud_backup_no_changes": "Inga ändringar sedan senaste synk.",
@@ -6402,6 +6399,24 @@
"status_paid_date": "Betald {date}",
"status_picker_aria": "Filtrera på status"
},
"notices": {
"bank_broken_one": "Bankkopplingen till {bank} har slutat fungera och hämtar inte längre transaktioner.",
"bank_broken_one_unnamed": "En bankkoppling har slutat fungera och hämtar inte längre transaktioner.",
"bank_broken_many": "{count} bankkopplingar har slutat fungera och hämtar inte längre transaktioner.",
"bank_broken_action": "Förnya kopplingen",
"skv_disconnected": "Anslutningen till Skatteverket behöver förnyas. Anslut igen med BankID.",
"skv_disconnected_action": "Anslut igen",
"backup_reauth": "Säkerhetskopieringen till {provider} är pausad: kontot behöver kopplas om.",
"backup_failing": "Den automatiska säkerhetskopieringen till {provider} misslyckas.",
"backup_action": "Öppna säkerhetskopiering",
"bank_expiring_one": "Banksamtycket för {bank} löper ut om {days, plural, =1 {1 dag} other {# dagar}}.",
"bank_expiring_many": "{count} banksamtycken löper ut inom kort.",
"bank_expiring_action": "Förnya samtycket",
"other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?",
"other_account_hint_action": "Byt konto",
"more_count": "+{count} till",
"dismiss": "Dölj"
},
"dashboard": {
"skv_promo_title": "Koppla Skatteverket",
"skv_promo_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Anslut med BankID på ett par minuter.",
@@ -6471,8 +6486,6 @@
"greeting_morning": "God morgon",
"greeting_day": "God dag",
"greeting_evening": "God kväll",
"other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?",
"other_account_hint_action": "Byt konto",
"resume_title": "Fortsätt",
"resume_invoice_draft": "Utkast: faktura till {customer}",
"resume_invoice_unsent": "Skicka faktura {number}",
@@ -0,0 +1,66 @@
-- Per-user dismissals for degraded-state notices (lib/notices).
--
-- lib/notices aggregates system/integration health (broken bank connections,
-- Skatteverket reconnect, failing cloud backups, expiring PSD2 consents,
-- wrong-account hint) into priority-ordered notices. A user can dismiss one;
-- the dismissal must be server-side and per (company, user) so it works
-- cross-device, unlike the localStorage patterns it replaces.
--
-- notice_id is an opaque text id that embeds a state discriminator
-- (connection id + status, consent expiry, error timestamp): a dismissal
-- hides exactly the state the user saw, and a NEW failure after a fix mints
-- a new id and surfaces again. Rows are therefore never updated in place
-- beyond re-stamping dismissed_at on a repeat dismissal (upsert), and stale
-- rows for states that no longer occur are harmless.
CREATE TABLE IF NOT EXISTS public.notice_dismissals (
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users ON DELETE CASCADE,
notice_id TEXT NOT NULL,
dismissed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (company_id, user_id, notice_id)
);
ALTER TABLE public.notice_dismissals ENABLE ROW LEVEL SECURITY;
-- All access is scoped to the user's own rows within their companies:
-- dismissals are personal (a colleague still sees the notice), so even
-- SELECT is bound to auth.uid(), not just company membership.
DROP POLICY IF EXISTS "Users see their own notice dismissals" ON public.notice_dismissals;
CREATE POLICY "Users see their own notice dismissals"
ON public.notice_dismissals FOR SELECT
USING (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
DROP POLICY IF EXISTS "Users insert their own notice dismissals" ON public.notice_dismissals;
CREATE POLICY "Users insert their own notice dismissals"
ON public.notice_dismissals FOR INSERT
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
-- Upserts re-stamp dismissed_at on conflict, which needs UPDATE.
DROP POLICY IF EXISTS "Users update their own notice dismissals" ON public.notice_dismissals;
CREATE POLICY "Users update their own notice dismissals"
ON public.notice_dismissals FOR UPDATE
USING (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
)
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
DROP POLICY IF EXISTS "Users delete their own notice dismissals" ON public.notice_dismissals;
CREATE POLICY "Users delete their own notice dismissals"
ON public.notice_dismissals FOR DELETE
USING (
company_id IN (SELECT public.user_company_ids())
AND user_id = auth.uid()
);
NOTIFY pgrst, 'reload schema';
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest'
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* pg-real coverage for 20260819190000_notice_dismissals.sql.
*
* Every policy on this table binds BOTH company membership and auth.uid(),
* which is the property that separates it from the rest of the schema: a
* dismissal is personal, so a colleague in the same company must keep seeing
* a notice the other member dismissed. Company-only scoping would silence a
* degraded-state notice for the whole company the moment one person hid it,
* which is exactly the failure the notices work exists to avoid.
*
* Note on the helper: withUserContext ALWAYS rolls back (tests/pg/setup.ts),
* so a write and its verification have to live inside the same callback.
* Rows that must survive for a cross-user read are seeded on the pool
* connection instead, which bypasses RLS as superuser.
*/
const NOTICE_ID = 'bank_connection_broken:11111111-1111-1111-1111-111111111111=error'
async function seedDismissal(
companyId: string,
userId: string,
noticeId: string = NOTICE_ID,
): Promise<void> {
await getPool().query(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
VALUES ($1, $2, $3)`,
[companyId, userId, noticeId],
)
}
async function countRows(companyId: string, userId: string): Promise<number> {
const res = await getPool().query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.notice_dismissals
WHERE company_id = $1 AND user_id = $2`,
[companyId, userId],
)
return Number(res.rows[0]!.n)
}
describe('notice_dismissals RLS', () => {
it('lets a member dismiss a notice for themselves and read it back', async () => {
const a = await seedCompany()
await withUserContext(a.userId, async (client) => {
const ins = await client.query(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
VALUES ($1, $2, $3)`,
[a.companyId, a.userId, NOTICE_ID],
)
expect(ins.rowCount).toBe(1)
const read = await client.query<{ notice_id: string }>(
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
[a.companyId],
)
expect(read.rows.map((r) => r.notice_id)).toEqual([NOTICE_ID])
})
})
it('re-stamps dismissed_at on a repeat dismissal (the upsert needs UPDATE)', async () => {
const a = await seedCompany()
await getPool().query(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id, dismissed_at)
VALUES ($1, $2, $3, now() - interval '3 days')`,
[a.companyId, a.userId, NOTICE_ID],
)
await withUserContext(a.userId, async (client) => {
const res = await client.query<{ dismissed_at: Date }>(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
VALUES ($1, $2, $3)
ON CONFLICT (company_id, user_id, notice_id)
DO UPDATE SET dismissed_at = now()
RETURNING dismissed_at`,
[a.companyId, a.userId, NOTICE_ID],
)
expect(res.rowCount).toBe(1)
// Without the UPDATE policy the ON CONFLICT branch is rejected outright,
// so reaching a fresh timestamp is the assertion that pins it.
const age = Date.now() - new Date(res.rows[0]!.dismissed_at).getTime()
expect(age).toBeLessThan(60_000)
})
})
it('keeps one member from seeing a colleague dismissal in the same company', async () => {
const a = await seedCompany()
const colleagueId = await insertAuthUser()
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
await seedDismissal(a.companyId, a.userId)
await withUserContext(colleagueId, async (client) => {
const res = await client.query(
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
[a.companyId],
)
expect(res.rowCount).toBe(0)
})
// The owner still sees their own row: the colleague is filtered, not the row.
await withUserContext(a.userId, async (client) => {
const res = await client.query(
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
[a.companyId],
)
expect(res.rowCount).toBe(1)
})
})
it('blocks dismissing on behalf of another user', async () => {
const a = await seedCompany()
const colleagueId = await insertAuthUser()
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
await withUserContext(a.userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
VALUES ($1, $2, $3)`,
[a.companyId, colleagueId, NOTICE_ID],
),
).rejects.toThrow(/row-level security/i)
})
})
it('blocks a non-member from dismissing or reading another company notice', async () => {
const a = await seedCompany()
const b = await seedCompany()
await seedDismissal(a.companyId, a.userId)
await withUserContext(b.userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
VALUES ($1, $2, $3)`,
[a.companyId, b.userId, NOTICE_ID],
),
).rejects.toThrow(/row-level security/i)
})
// A separate context: the rejected INSERT above aborts its transaction,
// so the read has to happen in a fresh one.
await withUserContext(b.userId, async (client) => {
const read = await client.query(
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
[a.companyId],
)
expect(read.rowCount).toBe(0)
})
})
it('deletes only the caller own rows, which is what reaping relies on', async () => {
const a = await seedCompany()
const colleagueId = await insertAuthUser()
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
await seedDismissal(a.companyId, a.userId)
await seedDismissal(a.companyId, colleagueId)
await withUserContext(colleagueId, async (client) => {
// An unqualified DELETE is still filtered to the caller's rows.
const res = await client.query(
`DELETE FROM public.notice_dismissals WHERE company_id = $1`,
[a.companyId],
)
expect(res.rowCount).toBe(1)
})
// Rolled back by the helper, so both rows are still there for the check
// that matters: the DELETE matched exactly one row, not both.
expect(await countRows(a.companyId, a.userId)).toBe(1)
expect(await countRows(a.companyId, colleagueId)).toBe(1)
})
it('scopes the primary key per (company, user, notice) so the same id can repeat', async () => {
const a = await seedCompany()
const b = await seedCompany()
const colleagueId = await insertAuthUser()
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
await seedDismissal(a.companyId, a.userId)
// Same notice id, different user in the same company: allowed.
await expect(seedDismissal(a.companyId, colleagueId)).resolves.not.toThrow()
// Same notice id, different company: allowed.
await expect(seedDismissal(b.companyId, b.userId)).resolves.not.toThrow()
// Exact same triple: rejected by the primary key.
await expect(seedDismissal(a.companyId, a.userId)).rejects.toThrow(
/notice_dismissals_pkey|duplicate key/i,
)
})
})