diff --git a/.claude/rules/design.md b/.claude/rules/design.md index 612dda72..56de8394 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -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). diff --git a/DECISIONS.md b/DECISIONS.md index fdc00991..aee9f8fa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1096,3 +1096,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 9aae0ac3..a9cd9288 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -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, diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index df7f3b14..4935c333 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -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 } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index b9bc9f86..f29b4e2d 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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) diff --git a/app/api/notices/__tests__/route.test.ts b/app/api/notices/__tests__/route.test.ts new file mode 100644 index 00000000..282e8e7d --- /dev/null +++ b/app/api/notices/__tests__/route.test.ts @@ -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', + }) + }) +}) diff --git a/app/api/notices/dismiss/__tests__/route.test.ts b/app/api/notices/dismiss/__tests__/route.test.ts new file mode 100644 index 00000000..ee3f9041 --- /dev/null +++ b/app/api/notices/dismiss/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/notices/dismiss/route.ts b/app/api/notices/dismiss/route.ts new file mode 100644 index 00000000..a6a48cff --- /dev/null +++ b/app/api/notices/dismiss/route.ts @@ -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 } }) +}) diff --git a/app/api/notices/route.ts b/app/api/notices/route.ts new file mode 100644 index 00000000..9fa5de5a --- /dev/null +++ b/app/api/notices/route.ts @@ -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 } }) +}) diff --git a/components/dashboard/BackupHealthBanner.tsx b/components/dashboard/BackupHealthBanner.tsx deleted file mode 100644 index 934e03ba..00000000 --- a/components/dashboard/BackupHealthBanner.tsx +++ /dev/null @@ -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 = { - 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(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 ( -
- -
-

- {allNeedReauth - ? t('ext_cloud_backup_banner_reauth', { provider: names }) - : t('ext_cloud_backup_banner_failing', { provider: names })} -

- - {t('ext_cloud_backup_banner_action')} - -
-
- ) -} diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index a7c5d77a..994c8b6c 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -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 (
- + {/* 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. */} + {/* Greeting hero (concept scene 14) */}
@@ -118,14 +127,6 @@ export default function DashboardContent({ {dateLine} {company?.name ? ` · ${company.name}` : ''}

- {otherAccountHint && ( - - {t('other_account_hint')} - - )}
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>(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 ( +
+ {visible.map((notice, index) => { + const override = actionOverrides[notice.category] + return ( + + {index === 0 && !expanded && moreCount > 0 && ( + <> + {' '} + + + )}{' '} + + + } + > + {t(notice.messageKey, notice.messageParams)} + + ) + })} +
+ ) +} diff --git a/components/ui/attn-line.tsx b/components/ui/attn-line.tsx index 65f48530..5600dbf5 100644 --- a/components/ui/attn-line.tsx +++ b/components/ui/attn-line.tsx @@ -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 (

{children} @@ -38,6 +44,7 @@ export function AttnLine({ children, action, className }: AttnLineProps) { )} )} + {trailing}

) } diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 3d284e87..166a75a3 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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), +}) diff --git a/lib/notices/__tests__/aggregate.test.ts b/lib/notices/__tests__/aggregate.test.ts new file mode 100644 index 00000000..e0e70cad --- /dev/null +++ b/lib/notices/__tests__/aggregate.test.ts @@ -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']) + }) +}) diff --git a/lib/notices/__tests__/categories.test.ts b/lib/notices/__tests__/categories.test.ts new file mode 100644 index 00000000..72eae542 --- /dev/null +++ b/lib/notices/__tests__/categories.test.ts @@ -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() + }) +}) diff --git a/lib/notices/aggregate.ts b/lib/notices/aggregate.ts new file mode 100644 index 00000000..cfe38b88 --- /dev/null +++ b/lib/notices/aggregate.ts @@ -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 { + 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 { + 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> { + 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() + } +} diff --git a/lib/notices/categories.ts b/lib/notices/categories.ts new file mode 100644 index 00000000..b34e0775 --- /dev/null +++ b/lib/notices/categories.ts @@ -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 `@`, + * 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 { + 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 { + 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 { + 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 = { + 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 { + 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 { + 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, + ) + } +} diff --git a/lib/notices/index.ts b/lib/notices/index.ts new file mode 100644 index 00000000..a905e1e8 --- /dev/null +++ b/lib/notices/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './categories' +export * from './aggregate' diff --git a/lib/notices/predicates.ts b/lib/notices/predicates.ts new file mode 100644 index 00000000..2c50a980 --- /dev/null +++ b/lib/notices/predicates.ts @@ -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' +} diff --git a/lib/notices/types.ts b/lib/notices/types.ts new file mode 100644 index 00000000..cc729b42 --- /dev/null +++ b/lib/notices/types.ts @@ -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 + /** 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 +} diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 5c1473cc..d6d27011 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1044,6 +1044,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { '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', diff --git a/messages/en.json b/messages/en.json index 46730def..9c37fabc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/messages/sv.json b/messages/sv.json index 65862910..c29088ee 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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}", diff --git a/supabase/migrations/20260819190000_notice_dismissals.sql b/supabase/migrations/20260819190000_notice_dismissals.sql new file mode 100644 index 00000000..f726479b --- /dev/null +++ b/supabase/migrations/20260819190000_notice_dismissals.sql @@ -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'; diff --git a/tests/pg/notice-dismissals.pg.test.ts b/tests/pg/notice-dismissals.pg.test.ts new file mode 100644 index 00000000..33f3ba7a --- /dev/null +++ b/tests/pg/notice-dismissals.pg.test.ts @@ -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 { + 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 { + 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, + ) + }) +})