feat(notices): one aggregated notice line instead of stacked degraded-state banners (#1733)
* feat(notices): lib/notices aggregator + single notice line on Hem
Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:
- lib/notices/{types,predicates,categories,aggregate}: five documented
categories with a fixed priority order; every predicate soft-fails to
null; pure decision helpers live in predicates.ts so 'use client' pages
can import them without pulling server-only modules. Broken supersedes
expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
notice_dismissals table (per company+user+notice_id, RLS user-scoped).
Notice ids embed a state discriminator, so a dismissal hides exactly
the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
boxed BackupHealthBanner card sat (banner deleted; its multi-provider
sentence logic moved into the backup_failing predicate), with a quiet
"+N till" inline expander. otherAccountHint joins the same list as the
lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
the reconnect decision from the shared skvStatusNeedsReconnect /
skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
räkenskapsinformation) to satisfy the full-archive contract.
SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy
Review fixes on the notice aggregator:
- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
(version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
SAME incident persists and so resurrected a dismissed notice daily. The
id is now stable per (provider, reason), and the opposite direction is
kept correct by stale-dismissal reaping in getCompanyNotices: when a
category is currently healthy, the caller's stored dismissals for that
category (matched on the 'category:' id prefix) are best-effort deleted,
so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
the other ids: bank ids embed connection id + status/expiry and skv
embeds the incident's first-error/expiry timestamp (markNeedsReconsent
only fires post-connect), all stable per incident; they get the same
reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
the English message: a bank_broken_one_unnamed message variant (sv + en)
is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
now collapses to count + first 8 hex of a sha256 over the sorted parts
(node:crypto, server-only) instead of concatenating uuids; single
connection ids stay human-readable. Dismiss schema cap tightened to 200
with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
healthy state reaps, new failure after reap resurfaces, hint never
reaped, failed reap swallowed, 30-connection id under 200 chars and
stable across orderings, unnamed-bank variant, sorted backup id stable
across cron re-stamps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(notices): pg-real coverage for the notice_dismissals policies
The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.
Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -1096,3 +1096,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-19] Skattekonto sync datumFrom bound applied uniformly to EF and AB (earliest fiscal_periods.period_start, no bound when none exists): an AB's skattekonto is the company's own so the bound is a no-op there, and one code path beats an entity_type branch; the EF case (owner's personal account, ~555 days of private pre-company history) is the one that needed it.
|
||||
[2026-08-19] company_settings.employer_registered chosen as the "does this EF employ anyone" signal for the avdragen-skatt gate (not an employees-table query): it is the existing AGI gate signal (20260717151000, attested pays_salaries OR any salary_run), already maintained for exactly this question, and one boolean on the settings row the rule context already fetches costs no extra round trip.
|
||||
[2026-08-19] requires_employer added as a data-driven skattekonto_rules column (set on the 'avdragen skatt,personalskatt,a-skatt' seed) instead of a hardcoded pattern match in TypeScript: the table exists precisely so rule semantics ship without redeploys, and a string match in code would silently miss per-company clones of the seed pattern.
|
||||
[2026-08-19] Notices Option A (contextual AttnLine slot fed by lib/notices) over a global layout notice strip: reuses convention 6 instead of adding a second piece of persistent chrome that could itself stack; Option B stays a founder call.
|
||||
[2026-08-19] notice_dismissals is server-side per (company, user, notice_id): dismissals must work cross-device and stay personal (a colleague still sees the notice), unlike the localStorage pattern SkatteverketPromoCard uses (left as-is here, follow-up).
|
||||
[2026-08-19] Notice ids embed a state discriminator (connection id + status/expiry/error timestamp) instead of a snooze timestamp: a dismissal hides exactly the state the user saw, and a NEW failure mints a new id that surfaces without any clock logic.
|
||||
|
||||
+12
-23
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { Notice } from '@/lib/notices/types'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
const requireAuthMock = vi.fn()
|
||||
const getCompanyNoticesMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/notices', () => ({
|
||||
getCompanyNotices: getCompanyNoticesMock,
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const notice: Notice = {
|
||||
id: 'skv_disconnected:needs_reconsent@2026-08-18T03:00:00Z',
|
||||
category: 'skv_disconnected',
|
||||
severity: 'error',
|
||||
messageKey: 'skv_disconnected',
|
||||
actionKey: 'skv_disconnected_action',
|
||||
actionHref: '/settings/tax',
|
||||
}
|
||||
|
||||
describe('GET /api/notices', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
getCompanyNoticesMock.mockResolvedValue([notice])
|
||||
})
|
||||
|
||||
it('returns 401 when the user is not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest('/api/notices'), { params: Promise.resolve({}) })
|
||||
expect(response.status).toBe(401)
|
||||
expect(getCompanyNoticesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the ordered notices for the active company and calling user', async () => {
|
||||
const { status, body } = await parseJsonResponse<{ data: { notices: Notice[] } }>(
|
||||
await GET(createMockRequest('/api/notices'), { params: Promise.resolve({}) }),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.notices).toEqual([notice])
|
||||
expect(getCompanyNoticesMock).toHaveBeenCalledWith(supabase, 'company-1', {
|
||||
userId: 'user-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
const requireAuthMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/notices/dismiss', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when the user is not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/notices/dismiss', {
|
||||
method: 'POST',
|
||||
body: { notice_id: 'skv_disconnected:x' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when notice_id is missing or empty', async () => {
|
||||
const missing = await POST(
|
||||
createMockRequest('/api/notices/dismiss', { method: 'POST', body: {} }),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
expect(missing.status).toBe(400)
|
||||
|
||||
const empty = await POST(
|
||||
createMockRequest('/api/notices/dismiss', { method: 'POST', body: { notice_id: '' } }),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
expect(empty.status).toBe(400)
|
||||
})
|
||||
|
||||
it('upserts the dismissal scoped to the calling user and company', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
const { status, body } = await parseJsonResponse<{ data: { dismissed: boolean } }>(
|
||||
await POST(
|
||||
createMockRequest('/api/notices/dismiss', {
|
||||
method: 'POST',
|
||||
body: { notice_id: 'bank_connection_broken:c1=expired' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.dismissed).toBe(true)
|
||||
|
||||
const upsertArgs = findCall('notice_dismissals', 'upsert')
|
||||
expect(upsertArgs?.[0]).toMatchObject({
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
notice_id: 'bank_connection_broken:c1=expired',
|
||||
})
|
||||
expect(upsertArgs?.[1]).toEqual({ onConflict: 'company_id,user_id,notice_id' })
|
||||
})
|
||||
|
||||
it('maps a failed write to a 500 error envelope', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const response = await POST(
|
||||
createMockRequest('/api/notices/dismiss', {
|
||||
method: 'POST',
|
||||
body: { notice_id: 'x' },
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
})
|
||||
@@ -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 } })
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
// Local mirror of the cloud-backup status shape: core must not import from
|
||||
// @/extensions/, so the fields we read are declared here.
|
||||
interface BackupProviderStatus {
|
||||
provider: string
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
interface BackupStatus {
|
||||
providers?: BackupProviderStatus[]
|
||||
// Pre-multi-provider shape, describing Google Drive alone.
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
/** Brand names stay untranslated; the sentence around them is localised. */
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
dropbox: 'Dropbox',
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning shown on the dashboard ONLY when a connected cloud backup is failing
|
||||
* (dead token or errored auto-sync). A backup that silently stops is worse
|
||||
* than none; this makes the failure visible where the user actually is.
|
||||
* Renders nothing when the extension is off, disconnected, or healthy.
|
||||
*
|
||||
* With more than one destination connected, a failure on either one surfaces:
|
||||
* a working Drive backup does not make a broken Dropbox backup acceptable.
|
||||
*/
|
||||
export default function BackupHealthBanner() {
|
||||
const t = useTranslations('extensions')
|
||||
const [status, setStatus] = useState<BackupStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return
|
||||
let cancelled = false
|
||||
fetch('/api/extensions/ext/cloud-backup/status')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => {
|
||||
if (!cancelled && body?.data) setStatus(body.data as BackupStatus)
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail silent: the dashboard must not degrade over a status probe.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!status) return null
|
||||
|
||||
const providers: BackupProviderStatus[] = status.providers ?? [
|
||||
{
|
||||
provider: 'google_drive',
|
||||
connected: status.connected,
|
||||
needs_reauth: status.needs_reauth,
|
||||
schedule: status.schedule,
|
||||
},
|
||||
]
|
||||
|
||||
const failing = providers.filter(
|
||||
(p) =>
|
||||
p.connected &&
|
||||
(p.needs_reauth || p.schedule?.last_auto_sync_status === 'error')
|
||||
)
|
||||
if (failing.length === 0) return null
|
||||
|
||||
// One sentence covering everything that is broken, so two dead connections
|
||||
// do not stack two banners on the dashboard.
|
||||
const names = failing
|
||||
.map((p) => PROVIDER_LABELS[p.provider] ?? p.provider)
|
||||
.join(' + ')
|
||||
const allNeedReauth = failing.every((p) => p.needs_reauth)
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{allNeedReauth
|
||||
? t('ext_cloud_backup_banner_reauth', { provider: names })
|
||||
: t('ext_cloud_backup_banner_failing', { provider: names })}
|
||||
</p>
|
||||
<Link
|
||||
href="/import#cloud-backup"
|
||||
className="mt-1 inline-block text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{t('ext_cloud_backup_banner_action')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import ResumePane from '@/components/dashboard/ResumePane'
|
||||
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
||||
import NoticeLines from '@/components/dashboard/NoticeLines'
|
||||
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
||||
import { AgentPromo } from '@/components/dashboard/AgentPromo'
|
||||
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
||||
import type { Notice } from '@/lib/notices/types'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import type { ResumeItem } from '@/lib/worklist/resume'
|
||||
import type { VatDeadlineLine } from '@/lib/onboarding/checklist'
|
||||
@@ -31,11 +31,12 @@ interface DashboardContentProps {
|
||||
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
|
||||
resumeItems: ResumeItem[]
|
||||
/**
|
||||
* True when this account looks bookkeeping-empty while a same-orgnr
|
||||
* company with real bookkeeping exists in another account (#1231): the
|
||||
* user probably signed in with the wrong login (stale BankID account).
|
||||
* Active degraded-state notices in priority order (lib/notices): broken or
|
||||
* expiring bank connections, Skatteverket reconnect, failing backups, the
|
||||
* wrong-account hint. Rendered as ONE attn line at the top with a quiet
|
||||
* "+N till" expander: never a stack of banners.
|
||||
*/
|
||||
otherAccountHint?: boolean
|
||||
notices?: Notice[]
|
||||
onboardingProgress?: OnboardingProgress
|
||||
initialSetup: InitialSetupState
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function DashboardContent({
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
resumeItems,
|
||||
otherAccountHint = false,
|
||||
notices = [],
|
||||
onboardingProgress,
|
||||
initialSetup,
|
||||
agentBuilt = true,
|
||||
@@ -107,7 +108,15 @@ export default function DashboardContent({
|
||||
|
||||
return (
|
||||
<div className="stagger-enter space-y-8">
|
||||
<BackupHealthBanner />
|
||||
{/* Degraded-state notices (lib/notices): one attn line, highest
|
||||
priority first, quiet "+N till" expander. The wrong-account hint
|
||||
participates in the same priority list instead of rendering its own
|
||||
unconditional line, and the old boxed BackupHealthBanner card lives
|
||||
on as the backup_failing category. */}
|
||||
<NoticeLines
|
||||
notices={notices}
|
||||
actionOverrides={{ other_account_hint: handleSwitchAccount }}
|
||||
/>
|
||||
|
||||
{/* Greeting hero (concept scene 14) */}
|
||||
<section>
|
||||
@@ -118,14 +127,6 @@ export default function DashboardContent({
|
||||
{dateLine}
|
||||
{company?.name ? ` · ${company.name}` : ''}
|
||||
</p>
|
||||
{otherAccountHint && (
|
||||
<AttnLine
|
||||
className="mt-3"
|
||||
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
|
||||
>
|
||||
{t('other_account_hint')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<NewUserChecklist
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import type { Notice, NoticeCategory } from '@/lib/notices/types'
|
||||
|
||||
interface NoticeLinesProps {
|
||||
/** Active notices in priority order (lib/notices getCompanyNotices). */
|
||||
notices: Notice[]
|
||||
/**
|
||||
* Per-category client-side action overrides: some notices need a handler
|
||||
* rather than a link (other_account_hint signs the user out).
|
||||
*/
|
||||
actionOverrides?: Partial<Record<NoticeCategory, () => void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hem notice slot: renders ONLY the single highest-priority active
|
||||
* notice as one AttnLine (convention 6), with a quiet "+N till" text link
|
||||
* expanding the rest inline when more are active. No boxed card, no overlay.
|
||||
*
|
||||
* Dismiss is optimistic and per user (POST /api/notices/dismiss): a failed
|
||||
* write just lets the notice reappear on the next load, which is the safe
|
||||
* direction for a health warning.
|
||||
*/
|
||||
export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLinesProps) {
|
||||
const t = useTranslations('notices')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set())
|
||||
|
||||
const active = notices.filter((n) => !hiddenIds.has(n.id))
|
||||
if (active.length === 0) return null
|
||||
const visible = expanded ? active : [active[0]]
|
||||
const moreCount = active.length - 1
|
||||
|
||||
function dismiss(notice: Notice) {
|
||||
setHiddenIds((prev) => new Set(prev).add(notice.id))
|
||||
// Fire-and-forget on purpose: on failure the notice simply returns on
|
||||
// the next load. Never block or error the dashboard over a dismissal.
|
||||
void fetch('/api/notices/dismiss', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notice_id: notice.id }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{visible.map((notice, index) => {
|
||||
const override = actionOverrides[notice.category]
|
||||
return (
|
||||
<AttnLine
|
||||
key={notice.id}
|
||||
action={
|
||||
override
|
||||
? { label: t(notice.actionKey), onClick: override }
|
||||
: { label: t(notice.actionKey), href: notice.actionHref }
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
{index === 0 && !expanded && moreCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('more_count', { count: moreCount })}
|
||||
</button>
|
||||
</>
|
||||
)}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(notice)}
|
||||
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t('dismiss')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t(notice.messageKey, notice.messageParams)}
|
||||
</AttnLine>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,12 @@ interface AttnLineProps {
|
||||
children: React.ReactNode
|
||||
/** Optional inline action at the end of the sentence. */
|
||||
action?: { label: string; href?: string; onClick?: () => void }
|
||||
/**
|
||||
* Optional quiet controls rendered inside the sentence after the action
|
||||
* (e.g. the notice line's "+N till" expander and dismiss). Style them
|
||||
* muted; the ochre tone belongs to the sentence and its action alone.
|
||||
*/
|
||||
trailing?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -13,7 +19,7 @@ interface AttnLineProps {
|
||||
* a single 12.5px line in the attn tone with an optional embedded action
|
||||
* link. Max one per page.
|
||||
*/
|
||||
export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
export function AttnLine({ children, action, trailing, className }: AttnLineProps) {
|
||||
return (
|
||||
<p className={cn('text-[12.5px] leading-5 text-attn', className)}>
|
||||
{children}
|
||||
@@ -38,6 +44,7 @@ export function AttnLine({ children, action, className }: AttnLineProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{trailing}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3669,3 +3669,17 @@ export const CompanyMigrationResetSchema = z.object({
|
||||
error: 'Bekräfta att den tidigare företagskopian sparas som arkiv',
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/notices/dismiss
|
||||
*
|
||||
* notice_id is an opaque lib/notices id (category + state discriminator).
|
||||
* Ids are bounded by construction: a multi-connection discriminator collapses
|
||||
* to a count plus an 8-char sha256 digest of the sorted parts (see
|
||||
* boundedDiscriminator in lib/notices/categories.ts), so the widest real id
|
||||
* is a single connection uuid plus a status/expiry, well under this cap;
|
||||
* anything longer is garbage.
|
||||
*/
|
||||
export const NoticeDismissSchema = z.object({
|
||||
notice_id: z.string().min(1).max(200),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createMockSupabase, createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { Notice } from '../types'
|
||||
|
||||
const detectMocks = vi.hoisted(() => ({
|
||||
broken: vi.fn(),
|
||||
skv: vi.fn(),
|
||||
backup: vi.fn(),
|
||||
expiring: vi.fn(),
|
||||
other: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../categories', () => ({
|
||||
detectBrokenBankConnections: detectMocks.broken,
|
||||
detectSkvDisconnected: detectMocks.skv,
|
||||
detectBackupFailing: detectMocks.backup,
|
||||
detectExpiringBankConnections: detectMocks.expiring,
|
||||
detectOtherAccountHint: detectMocks.other,
|
||||
}))
|
||||
|
||||
import { getCompanyNotices } from '../aggregate'
|
||||
|
||||
const notice = (category: Notice['category'], id: string): Notice => ({
|
||||
id,
|
||||
category,
|
||||
severity: 'warning',
|
||||
messageKey: category,
|
||||
actionKey: `${category}_action`,
|
||||
actionHref: '/x',
|
||||
})
|
||||
|
||||
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
||||
const supabase = mockSupabase as unknown as SupabaseClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
detectMocks.broken.mockResolvedValue(null)
|
||||
detectMocks.skv.mockResolvedValue(null)
|
||||
detectMocks.backup.mockResolvedValue(null)
|
||||
detectMocks.expiring.mockResolvedValue(null)
|
||||
detectMocks.other.mockResolvedValue(null)
|
||||
mockResult({ data: [] }) // notice_dismissals: none
|
||||
})
|
||||
|
||||
describe('getCompanyNotices', () => {
|
||||
it('returns an empty list when nothing is degraded', async () => {
|
||||
await expect(
|
||||
getCompanyNotices(supabase, 'company-1', { userId: 'user-1' }),
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('orders notices by the documented priority regardless of resolution order', async () => {
|
||||
detectMocks.other.mockResolvedValue(notice('other_account_hint', 'other_account_hint'))
|
||||
detectMocks.expiring.mockResolvedValue(notice('bank_connection_expiring', 'exp:1'))
|
||||
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
||||
detectMocks.backup.mockResolvedValue(notice('backup_failing', 'backup:1'))
|
||||
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
|
||||
|
||||
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
||||
expect(notices.map((n) => n.category)).toEqual([
|
||||
'bank_connection_broken',
|
||||
'skv_disconnected',
|
||||
'backup_failing',
|
||||
'bank_connection_expiring',
|
||||
'other_account_hint',
|
||||
])
|
||||
})
|
||||
|
||||
it('hides dismissed notice ids and keeps the rest', async () => {
|
||||
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
||||
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
|
||||
mockResult({ data: [{ notice_id: 'broken:1' }] })
|
||||
|
||||
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
||||
expect(notices.map((n) => n.id)).toEqual(['skv:1'])
|
||||
})
|
||||
|
||||
it('does NOT hide a notice whose state discriminator changed since the dismissal', async () => {
|
||||
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:2'))
|
||||
mockResult({ data: [{ notice_id: 'broken:1' }] })
|
||||
|
||||
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
||||
expect(notices.map((n) => n.id)).toEqual(['broken:2'])
|
||||
})
|
||||
|
||||
it('shows everything when the dismissal read fails (over-show beats hiding a real problem)', async () => {
|
||||
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
|
||||
mockResult({ error: { message: 'boom' } })
|
||||
|
||||
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
|
||||
expect(notices.map((n) => n.id)).toEqual(['broken:1'])
|
||||
})
|
||||
|
||||
it('passes the caller identity through to the per-user predicates', async () => {
|
||||
const now = new Date('2026-08-19T12:00:00Z')
|
||||
await getCompanyNotices(supabase, 'company-1', { userId: 'user-1', now })
|
||||
expect(detectMocks.skv).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', now)
|
||||
expect(detectMocks.expiring).toHaveBeenCalledWith(supabase, 'company-1', now)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale-dismissal reaping (contract in lib/notices/types.ts)', () => {
|
||||
// A queued mock records builder calls, so the delete (or its absence) is
|
||||
// observable. The backup id is timestamp-free and identical per incident,
|
||||
// making it the category whose resurface behavior depends on reaping.
|
||||
const BACKUP_ID = 'backup_failing:google_drive=sync_error'
|
||||
const backupNotice = notice('backup_failing', BACKUP_ID)
|
||||
const queued = createQueuedMockSupabase()
|
||||
const qSupabase = queued.supabase as unknown as SupabaseClient
|
||||
|
||||
beforeEach(() => {
|
||||
queued.reset()
|
||||
})
|
||||
|
||||
it('keeps a persisting failure dismissed across two aggregations (no reap while failing)', async () => {
|
||||
detectMocks.backup.mockResolvedValue(backupNotice)
|
||||
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
||||
const first = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
||||
const second = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
||||
|
||||
expect(first).toEqual([])
|
||||
expect(second).toEqual([])
|
||||
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
|
||||
})
|
||||
|
||||
it('reaps the stored dismissal once the category is healthy again', async () => {
|
||||
// All detects resolve null (healthy) via the outer beforeEach.
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] }) // dismissal read
|
||||
queued.enqueue({ data: null }) // delete result
|
||||
|
||||
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
||||
|
||||
expect(notices).toEqual([])
|
||||
expect(queued.findCalls('notice_dismissals', 'delete').length).toBe(1)
|
||||
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
|
||||
})
|
||||
|
||||
it('resurfaces a NEW failure after the healthy spell reaped the dismissal', async () => {
|
||||
// error -> dismiss: hidden while the incident persists.
|
||||
detectMocks.backup.mockResolvedValue(backupNotice)
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
||||
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
|
||||
|
||||
// success: the read reaps the now-stale dismissal.
|
||||
detectMocks.backup.mockResolvedValue(null)
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
||||
queued.enqueue({ data: null })
|
||||
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
|
||||
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
|
||||
|
||||
// new error later, same id: the dismissal is gone, so it surfaces again.
|
||||
detectMocks.backup.mockResolvedValue(backupNotice)
|
||||
queued.enqueue({ data: [] })
|
||||
expect(
|
||||
(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).map((n) => n.id),
|
||||
).toEqual([BACKUP_ID])
|
||||
})
|
||||
|
||||
it('never reaps other_account_hint: its id has no category-prefix discriminator', async () => {
|
||||
queued.enqueue({ data: [{ notice_id: 'other_account_hint' }] })
|
||||
await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
||||
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
|
||||
})
|
||||
|
||||
it('swallows a failed reap and still returns the computed notices', async () => {
|
||||
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'bank_connection_broken:c1=expired'))
|
||||
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
|
||||
queued.enqueue({ error: { message: 'boom' } }) // delete fails
|
||||
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
|
||||
expect(notices.map((n) => n.id)).toEqual(['bank_connection_broken:c1=expired'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const otherAccountHintMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/extensions/_generated/enabled-extensions', () => ({
|
||||
ENABLED_EXTENSION_IDS: new Set(['cloud-backup']),
|
||||
}))
|
||||
vi.mock('@/lib/company/other-account-hint', () => ({
|
||||
shouldShowOtherAccountHint: otherAccountHintMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
detectBackupFailing,
|
||||
detectBrokenBankConnections,
|
||||
detectExpiringBankConnections,
|
||||
detectOtherAccountHint,
|
||||
detectSkvDisconnected,
|
||||
expiringBankConnectionsFrom,
|
||||
skvAuthErrorNeedsReconnect,
|
||||
skvStatusNeedsReconnect,
|
||||
} from '../categories'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
|
||||
const supabase = mockSupabase as unknown as SupabaseClient
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
const NOW = new Date('2026-08-19T12:00:00Z')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
describe('expiringBankConnectionsFrom (pure day-math)', () => {
|
||||
const conn = (days: number | null) => ({
|
||||
id: 'c1',
|
||||
bank_name: 'SEB',
|
||||
consent_expires:
|
||||
days === null ? null : new Date(NOW.getTime() + days * 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
it('includes a consent expiring in exactly 14 days', () => {
|
||||
expect(expiringBankConnectionsFrom([conn(14)], NOW)).toEqual([
|
||||
{ id: 'c1', bank_name: 'SEB', days_left: 14 },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes a consent expiring in 15 days', () => {
|
||||
expect(expiringBankConnectionsFrom([conn(15)], NOW)).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes an already-expired consent (days_left <= 0): that is broken, not expiring', () => {
|
||||
expect(expiringBankConnectionsFrom([conn(-1)], NOW)).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes rows without a consent date', () => {
|
||||
expect(expiringBankConnectionsFrom([conn(null)], NOW)).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds partial days up (1 hour left = 1 day)', () => {
|
||||
const row = {
|
||||
id: 'c1',
|
||||
bank_name: 'SEB',
|
||||
consent_expires: new Date(NOW.getTime() + 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
expect(expiringBankConnectionsFrom([row], NOW)).toEqual([
|
||||
{ id: 'c1', bank_name: 'SEB', days_left: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('skvStatusNeedsReconnect (pure)', () => {
|
||||
it('fires on needsReconsent', () => {
|
||||
expect(skvStatusNeedsReconnect({ connected: true, needsReconsent: true })).toBe(true)
|
||||
})
|
||||
it('fires on expired without refresh capability', () => {
|
||||
expect(skvStatusNeedsReconnect({ connected: true, expired: true, canRefresh: false })).toBe(true)
|
||||
})
|
||||
it('stays quiet when expired but refreshable', () => {
|
||||
expect(skvStatusNeedsReconnect({ connected: true, expired: true, canRefresh: true })).toBe(false)
|
||||
})
|
||||
it('stays quiet when not connected', () => {
|
||||
expect(skvStatusNeedsReconnect({ connected: false, needsReconsent: true })).toBe(false)
|
||||
})
|
||||
it('stays quiet when env-disabled', () => {
|
||||
expect(
|
||||
skvStatusNeedsReconnect({ connected: true, disabled: true, needsReconsent: true }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skvAuthErrorNeedsReconnect (pure)', () => {
|
||||
it('treats 401 with a non-NOT_CONNECTED code as reconnect', () => {
|
||||
expect(skvAuthErrorNeedsReconnect(401, 'SESSION_EXPIRED')).toBe(true)
|
||||
expect(skvAuthErrorNeedsReconnect(401, undefined)).toBe(true)
|
||||
})
|
||||
it('treats 401 NOT_CONNECTED as not-connected, not reconnect', () => {
|
||||
expect(skvAuthErrorNeedsReconnect(401, 'NOT_CONNECTED')).toBe(false)
|
||||
})
|
||||
it('never fires on non-401 statuses', () => {
|
||||
expect(skvAuthErrorNeedsReconnect(500, 'SESSION_EXPIRED')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectBrokenBankConnections', () => {
|
||||
it('returns null when no connection is broken', async () => {
|
||||
enqueue({ data: [] })
|
||||
await expect(detectBrokenBankConnections(supabase, COMPANY)).resolves.toBeNull()
|
||||
expect(findCall('bank_connections', 'in')).toEqual(['status', ['expired', 'error']])
|
||||
})
|
||||
|
||||
it('shapes a single broken connection with its bank name', async () => {
|
||||
enqueue({ data: [{ id: 'c1', status: 'expired', bank_name: 'SEB' }] })
|
||||
const notice = await detectBrokenBankConnections(supabase, COMPANY)
|
||||
expect(notice).toMatchObject({
|
||||
id: 'bank_connection_broken:c1=expired',
|
||||
category: 'bank_connection_broken',
|
||||
severity: 'error',
|
||||
messageKey: 'bank_broken_one',
|
||||
messageParams: { bank: 'SEB' },
|
||||
actionHref: '/settings/banking',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the unnamed message variant when the single broken connection has no bank name', async () => {
|
||||
enqueue({ data: [{ id: 'c1', status: 'expired', bank_name: null }] })
|
||||
const notice = await detectBrokenBankConnections(supabase, COMPANY)
|
||||
expect(notice).toMatchObject({
|
||||
id: 'bank_connection_broken:c1=expired',
|
||||
messageKey: 'bank_broken_one_unnamed',
|
||||
})
|
||||
expect(notice?.messageParams).toBeUndefined()
|
||||
})
|
||||
|
||||
it('folds several broken connections into one counted notice with a bounded, stable id', async () => {
|
||||
const digest = createHash('sha256').update('c1=expired,c2=error').digest('hex').slice(0, 8)
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c2', status: 'error', bank_name: 'Nordea' },
|
||||
{ id: 'c1', status: 'expired', bank_name: 'SEB' },
|
||||
],
|
||||
})
|
||||
const notice = await detectBrokenBankConnections(supabase, COMPANY)
|
||||
expect(notice).toMatchObject({
|
||||
id: `bank_connection_broken:2@${digest}`,
|
||||
messageKey: 'bank_broken_many',
|
||||
messageParams: { count: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the id under 200 chars for 30 broken connections, stable across orderings', async () => {
|
||||
const rows = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `3f8b2c1a-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
status: i % 2 === 0 ? 'expired' : 'error',
|
||||
bank_name: null,
|
||||
}))
|
||||
enqueue({ data: rows })
|
||||
enqueue({ data: [...rows].reverse() })
|
||||
const first = await detectBrokenBankConnections(supabase, COMPANY)
|
||||
const second = await detectBrokenBankConnections(supabase, COMPANY)
|
||||
expect(first?.id).toBeDefined()
|
||||
expect(first?.id.length).toBeLessThan(200)
|
||||
expect(first?.id).toMatch(/^bank_connection_broken:30@[0-9a-f]{8}$/)
|
||||
expect(second?.id).toBe(first?.id)
|
||||
})
|
||||
|
||||
it('soft-fails to null on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(detectBrokenBankConnections(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectExpiringBankConnections', () => {
|
||||
const expires = new Date(NOW.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
it('only considers active connections (broken supersedes expiring)', async () => {
|
||||
enqueue({ data: [] })
|
||||
await detectExpiringBankConnections(supabase, COMPANY, NOW)
|
||||
expect(findCalls('bank_connections', 'eq')).toEqual([
|
||||
['company_id', COMPANY],
|
||||
['status', 'active'],
|
||||
])
|
||||
})
|
||||
|
||||
it('discriminates the id on the consent date, not the ticking countdown', async () => {
|
||||
enqueue({ data: [{ id: 'c1', bank_name: 'SEB', consent_expires: expires }] })
|
||||
const notice = await detectExpiringBankConnections(supabase, COMPANY, NOW)
|
||||
expect(notice).toMatchObject({
|
||||
id: `bank_connection_expiring:c1=${expires}`,
|
||||
category: 'bank_connection_expiring',
|
||||
severity: 'warning',
|
||||
messageKey: 'bank_expiring_one',
|
||||
messageParams: { bank: 'SEB', days: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses several expiring consents into a bounded digest id', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c1', bank_name: 'SEB', consent_expires: expires },
|
||||
{ id: 'c2', bank_name: 'Nordea', consent_expires: expires },
|
||||
],
|
||||
})
|
||||
const notice = await detectExpiringBankConnections(supabase, COMPANY, NOW)
|
||||
expect(notice?.id).toMatch(/^bank_connection_expiring:2@[0-9a-f]{8}$/)
|
||||
expect(notice).toMatchObject({ messageKey: 'bank_expiring_many', messageParams: { count: 2 } })
|
||||
})
|
||||
|
||||
it('returns null when every consent is further out than 14 days', async () => {
|
||||
const far = new Date(NOW.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
enqueue({ data: [{ id: 'c1', bank_name: 'SEB', consent_expires: far }] })
|
||||
await expect(detectExpiringBankConnections(supabase, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('soft-fails to null on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(detectExpiringBankConnections(supabase, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectSkvDisconnected', () => {
|
||||
it('returns null when no token row exists (not connected)', async () => {
|
||||
enqueue({ data: null })
|
||||
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('fires on a needs_reconsent row, discriminated by the error timestamp', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
status: 'needs_reconsent',
|
||||
expires_at: '2026-08-19T10:00:00Z',
|
||||
refresh_token: 'ciphertext',
|
||||
refresh_count: 1,
|
||||
last_error_at: '2026-08-18T03:00:00Z',
|
||||
},
|
||||
})
|
||||
const notice = await detectSkvDisconnected(supabase, USER, COMPANY, NOW)
|
||||
expect(notice).toMatchObject({
|
||||
id: 'skv_disconnected:needs_reconsent@2026-08-18T03:00:00Z',
|
||||
category: 'skv_disconnected',
|
||||
severity: 'error',
|
||||
actionHref: '/settings/tax',
|
||||
})
|
||||
expect(findCall('skatteverket_tokens', 'eq')).toEqual(['user_id', USER])
|
||||
})
|
||||
|
||||
it('fires on an expired token with no refresh token left', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
status: 'active',
|
||||
expires_at: '2026-08-19T10:00:00Z',
|
||||
refresh_token: null,
|
||||
refresh_count: 0,
|
||||
last_error_at: null,
|
||||
},
|
||||
})
|
||||
const notice = await detectSkvDisconnected(supabase, USER, COMPANY, NOW)
|
||||
expect(notice).toMatchObject({ id: 'skv_disconnected:expired@2026-08-19T10:00:00Z' })
|
||||
})
|
||||
|
||||
it('fires on an expired token whose refresh budget is exhausted', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
status: 'active',
|
||||
expires_at: '2026-08-19T10:00:00Z',
|
||||
refresh_token: 'ciphertext',
|
||||
refresh_count: 10,
|
||||
last_error_at: null,
|
||||
},
|
||||
})
|
||||
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toMatchObject({
|
||||
category: 'skv_disconnected',
|
||||
})
|
||||
})
|
||||
|
||||
it('stays quiet while the token is expired but still refreshable', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
status: 'active',
|
||||
expires_at: '2026-08-19T10:00:00Z',
|
||||
refresh_token: 'ciphertext',
|
||||
refresh_count: 3,
|
||||
last_error_at: null,
|
||||
},
|
||||
})
|
||||
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet on a healthy, unexpired token', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
status: 'active',
|
||||
expires_at: '2026-08-19T14:00:00Z',
|
||||
refresh_token: 'ciphertext',
|
||||
refresh_count: 0,
|
||||
last_error_at: null,
|
||||
},
|
||||
})
|
||||
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('soft-fails to null on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectBackupFailing', () => {
|
||||
it('returns null when nothing is connected', async () => {
|
||||
enqueue({ data: [] })
|
||||
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when connected backups are healthy', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ key: 'google_drive_connection', value: { status: 'active' } },
|
||||
{ key: 'google_drive_schedule', value: { last_auto_sync_status: 'success' } },
|
||||
],
|
||||
})
|
||||
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('fires the reauth message when every failing provider needs reauth', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
key: 'google_drive_connection',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-08-17T00:00:00Z' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const notice = await detectBackupFailing(supabase, COMPANY)
|
||||
expect(notice).toMatchObject({
|
||||
id: 'backup_failing:google_drive=reauth',
|
||||
category: 'backup_failing',
|
||||
severity: 'error',
|
||||
messageKey: 'backup_reauth',
|
||||
messageParams: { provider: 'Google Drive' },
|
||||
actionHref: '/import#cloud-backup',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the id stable while the cron re-stamps last_auto_sync_at on the SAME incident', async () => {
|
||||
const failingRun = (stampedAt: string) => [
|
||||
{ key: 'google_drive_connection', value: { status: 'active' } },
|
||||
{
|
||||
key: 'google_drive_schedule',
|
||||
value: { last_auto_sync_status: 'error', last_auto_sync_at: stampedAt },
|
||||
},
|
||||
]
|
||||
enqueue({ data: failingRun('2026-08-18T02:00:00Z') })
|
||||
enqueue({ data: failingRun('2026-08-19T02:00:00Z') })
|
||||
const first = await detectBackupFailing(supabase, COMPANY)
|
||||
const second = await detectBackupFailing(supabase, COMPANY)
|
||||
expect(first?.id).toBe('backup_failing:google_drive=sync_error')
|
||||
expect(second?.id).toBe(first?.id)
|
||||
})
|
||||
|
||||
it('folds two failing providers into ONE notice with both names and a sorted id', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ key: 'google_drive_connection', value: { status: 'needs_reauth' } },
|
||||
{ key: 'dropbox_connection', value: { status: 'active' } },
|
||||
{
|
||||
key: 'dropbox_schedule',
|
||||
value: { last_auto_sync_status: 'error', last_auto_sync_at: '2026-08-18T02:00:00Z' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const notice = await detectBackupFailing(supabase, COMPANY)
|
||||
expect(notice).toMatchObject({
|
||||
id: 'backup_failing:dropbox=sync_error,google_drive=reauth',
|
||||
messageKey: 'backup_failing',
|
||||
messageParams: { provider: 'Google Drive + Dropbox' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores an errored schedule for a provider without a connection', async () => {
|
||||
enqueue({
|
||||
data: [{ key: 'dropbox_schedule', value: { last_auto_sync_status: 'error' } }],
|
||||
})
|
||||
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('soft-fails to null on query error', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
await expect(detectBackupFailing(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectOtherAccountHint', () => {
|
||||
it('shapes the hint as the lowest-priority notice when the detector fires', async () => {
|
||||
otherAccountHintMock.mockResolvedValue(true)
|
||||
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toMatchObject({
|
||||
id: 'other_account_hint',
|
||||
category: 'other_account_hint',
|
||||
severity: 'warning',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the detector stays quiet', async () => {
|
||||
otherAccountHintMock.mockResolvedValue(false)
|
||||
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('soft-fails to null when the detector throws', async () => {
|
||||
otherAccountHintMock.mockRejectedValue(new Error('boom'))
|
||||
await expect(detectOtherAccountHint(supabase, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('never-throws contract', () => {
|
||||
it('every predicate resolves null when the client itself throws', async () => {
|
||||
const throwing = {
|
||||
from: () => {
|
||||
throw new Error('client exploded')
|
||||
},
|
||||
} as unknown as SupabaseClient
|
||||
otherAccountHintMock.mockRejectedValue(new Error('client exploded'))
|
||||
await expect(detectBrokenBankConnections(throwing, COMPANY)).resolves.toBeNull()
|
||||
await expect(detectExpiringBankConnections(throwing, COMPANY, NOW)).resolves.toBeNull()
|
||||
await expect(detectSkvDisconnected(throwing, USER, COMPANY, NOW)).resolves.toBeNull()
|
||||
await expect(detectBackupFailing(throwing, COMPANY)).resolves.toBeNull()
|
||||
await expect(detectOtherAccountHint(throwing, COMPANY)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { NOTICE_PRIORITY, type Notice } from './types'
|
||||
import {
|
||||
detectBackupFailing,
|
||||
detectBrokenBankConnections,
|
||||
detectExpiringBankConnections,
|
||||
detectOtherAccountHint,
|
||||
detectSkvDisconnected,
|
||||
} from './categories'
|
||||
|
||||
const log = createLogger('notices')
|
||||
|
||||
export interface GetCompanyNoticesOptions {
|
||||
/** Caller's user id: Skatteverket connections and dismissals are per user. */
|
||||
userId: string
|
||||
now?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* All active, non-dismissed notices for a company, in NOTICE_PRIORITY order.
|
||||
* Predicates run in one parallel burst and individually soft-fail to null
|
||||
* (see categories.ts), so this is safe to call from server components on
|
||||
* every render, mirroring getWorklistCounts.
|
||||
*
|
||||
* Dismissals are per (company, user, notice id); notice ids embed a state
|
||||
* discriminator, so a dismissal hides exactly the state the user saw and a
|
||||
* NEW failure surfaces again. A failed dismissal read degrades to showing
|
||||
* everything: over-showing a real problem beats silently hiding it.
|
||||
*
|
||||
* This read path also reaps stale dismissals: when a category is currently
|
||||
* healthy, the caller's stored dismissals for that category describe a state
|
||||
* that no longer exists and are deleted (best effort). See the reaping
|
||||
* contract on Notice.id in lib/notices/types.ts.
|
||||
*/
|
||||
export async function getCompanyNotices(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
{ userId, now = new Date() }: GetCompanyNoticesOptions,
|
||||
): Promise<Notice[]> {
|
||||
const [candidates, dismissedIds] = await Promise.all([
|
||||
Promise.all([
|
||||
detectBrokenBankConnections(supabase, companyId),
|
||||
detectSkvDisconnected(supabase, userId, companyId, now),
|
||||
detectBackupFailing(supabase, companyId),
|
||||
detectExpiringBankConnections(supabase, companyId, now),
|
||||
detectOtherAccountHint(supabase, companyId),
|
||||
]),
|
||||
fetchDismissedIds(supabase, companyId, userId),
|
||||
])
|
||||
|
||||
const byCategory = new Map(
|
||||
candidates.filter((n): n is Notice => n !== null).map((n) => [n.category, n]),
|
||||
)
|
||||
|
||||
// Stale = dismissed under a category (id prefix 'category:') that is
|
||||
// currently healthy. other_account_hint's exact id carries no ':' and is
|
||||
// deliberately never reaped: its condition either holds or stops holding.
|
||||
const staleIds = [...dismissedIds].filter((id) => {
|
||||
const category = NOTICE_PRIORITY.find((c) => id.startsWith(`${c}:`))
|
||||
return category !== undefined && !byCategory.has(category)
|
||||
})
|
||||
await reapStaleDismissals(supabase, companyId, userId, staleIds)
|
||||
|
||||
return NOTICE_PRIORITY.map((category) => byCategory.get(category)).filter(
|
||||
(n): n is Notice => n !== undefined && !dismissedIds.has(n.id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort deletion of dismissals whose category has recovered (see the
|
||||
* reaping contract in lib/notices/types.ts): keeping them would hide the NEXT
|
||||
* failure for ids without a volatile discriminator (backup_failing). Failures
|
||||
* are swallowed: reaping is hygiene, never worth failing a read for, and an
|
||||
* un-reaped row is retried on the next read. A predicate that soft-failed to
|
||||
* null counts as healthy here; the worst case is one extra re-dismiss, which
|
||||
* errs on the same side as everything else in this module: over-showing.
|
||||
*/
|
||||
async function reapStaleDismissals(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
staleIds: string[],
|
||||
): Promise<void> {
|
||||
if (staleIds.length === 0) return
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('notice_dismissals')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.in('notice_id', staleIds)
|
||||
if (error) {
|
||||
log.warn('stale notice dismissal reap failed', { companyId, reason: error.message })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('stale notice dismissal reap failed', {
|
||||
companyId,
|
||||
reason: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDismissedIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
): Promise<Set<string>> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('notice_dismissals')
|
||||
.select('notice_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
if (error) {
|
||||
log.error('notice dismissal read failed', { companyId, reason: error.message })
|
||||
return new Set()
|
||||
}
|
||||
return new Set((data ?? []).map((r) => r.notice_id as string))
|
||||
} catch (err) {
|
||||
log.error('notice dismissal read failed', {
|
||||
companyId,
|
||||
reason: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Per-category notice predicates: the single owner of every degraded-state
|
||||
* detection. Surfaces (Hem, /api/notices, per-page attn lines) must call
|
||||
* these, or the exported pure helpers, instead of inlining their own checks;
|
||||
* see lib/notices/types.ts for each category's pending/done definition.
|
||||
*
|
||||
* Every predicate soft-fails to null with a logged error: a broken health
|
||||
* check must never take down the dashboard or the home page.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { shouldShowOtherAccountHint } from '@/lib/company/other-account-hint'
|
||||
import { expiringBankConnectionsFrom, skvStatusNeedsReconnect } from './predicates'
|
||||
import type { Notice } from './types'
|
||||
|
||||
// The pure decision layer lives in ./predicates (client-safe: 'use client'
|
||||
// pages import it directly, since this file pulls in server-only modules).
|
||||
// Re-exported here so server code has one import path for the whole domain.
|
||||
export * from './predicates'
|
||||
|
||||
const log = createLogger('notices')
|
||||
|
||||
function logAndNull(
|
||||
category: string,
|
||||
companyId: string,
|
||||
error: { message?: string } | null,
|
||||
): null {
|
||||
// companyId is a structured field so repeated failures can be correlated
|
||||
// to a tenant in monitoring (mirrors lib/worklist logAndZero).
|
||||
log.error(`notice predicate failed: ${category}`, { companyId, reason: error?.message })
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound a multi-part id discriminator. A single part stays human-readable
|
||||
* (a connection id plus its status/expiry); several parts, each embedding a
|
||||
* uuid, collapse to `<count>@<first 8 hex of sha256 over the sorted parts>`,
|
||||
* so the id stays far below the dismiss schema cap (lib/api/schemas.ts) no
|
||||
* matter how many connections fold into one notice, and is stable across
|
||||
* row orderings. Server-only (node:crypto), which this module already is.
|
||||
*/
|
||||
function boundedDiscriminator(parts: string[]): string {
|
||||
if (parts.length === 1) return parts[0]
|
||||
const digest = createHash('sha256')
|
||||
.update([...parts].sort().join(','))
|
||||
.digest('hex')
|
||||
.slice(0, 8)
|
||||
return `${parts.length}@${digest}`
|
||||
}
|
||||
|
||||
// ── Predicates (one query each; soft-fail to null) ──
|
||||
|
||||
/**
|
||||
* bank_connection_broken: connections whose status is already terminal
|
||||
* ('expired' | 'error'): same predicate as BankSyncStatusChip's "attention"
|
||||
* state. Disjoint from bank_connection_expiring by the status filter.
|
||||
*/
|
||||
export async function detectBrokenBankConnections(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<Notice | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, bank_name')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['expired', 'error'])
|
||||
if (error) return logAndNull('bank_connection_broken', companyId, error)
|
||||
const rows = data ?? []
|
||||
if (rows.length === 0) return null
|
||||
const discriminator = boundedDiscriminator(rows.map((r) => `${r.id}=${r.status}`))
|
||||
// A NULL bank_name switches to the unnamed message variant instead of
|
||||
// interpolating a fallback word, which would leak Swedish into English.
|
||||
const bankName = rows.length === 1 ? ((rows[0].bank_name as string | null) || null) : null
|
||||
return {
|
||||
id: `bank_connection_broken:${discriminator}`,
|
||||
category: 'bank_connection_broken',
|
||||
severity: 'error',
|
||||
messageKey:
|
||||
rows.length === 1
|
||||
? bankName
|
||||
? 'bank_broken_one'
|
||||
: 'bank_broken_one_unnamed'
|
||||
: 'bank_broken_many',
|
||||
messageParams:
|
||||
rows.length === 1
|
||||
? bankName
|
||||
? { bank: bankName }
|
||||
: undefined
|
||||
: { count: rows.length },
|
||||
actionKey: 'bank_broken_action',
|
||||
actionHref: '/settings/banking',
|
||||
}
|
||||
} catch (err) {
|
||||
return logAndNull(
|
||||
'bank_connection_broken',
|
||||
companyId,
|
||||
err instanceof Error ? { message: err.message } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bank_connection_expiring: active connections whose PSD2 consent runs out
|
||||
* within 14 days. Only status = 'active' rows are considered, so a
|
||||
* connection that has ALREADY failed never shows as both broken and
|
||||
* expiring (broken supersedes expiring for the same connection).
|
||||
*/
|
||||
export async function detectExpiringBankConnections(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<Notice | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, consent_expires')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
.not('consent_expires', 'is', null)
|
||||
if (error) return logAndNull('bank_connection_expiring', companyId, error)
|
||||
const expiring = expiringBankConnectionsFrom(data ?? [], now)
|
||||
if (expiring.length === 0) return null
|
||||
// The consent date, not days_left, discriminates the id: the countdown
|
||||
// ticking from 14 to 13 days must not resurrect a dismissed notice.
|
||||
const consentByid = new Map(
|
||||
(data ?? []).map((r) => [r.id as string, r.consent_expires as string | null]),
|
||||
)
|
||||
const discriminator = boundedDiscriminator(
|
||||
expiring.map((c) => `${c.id}=${consentByid.get(c.id) ?? ''}`),
|
||||
)
|
||||
return {
|
||||
id: `bank_connection_expiring:${discriminator}`,
|
||||
category: 'bank_connection_expiring',
|
||||
severity: 'warning',
|
||||
messageKey: expiring.length === 1 ? 'bank_expiring_one' : 'bank_expiring_many',
|
||||
messageParams:
|
||||
expiring.length === 1
|
||||
? { bank: expiring[0].bank_name, days: expiring[0].days_left }
|
||||
: { count: expiring.length },
|
||||
actionKey: 'bank_expiring_action',
|
||||
actionHref: '/settings/banking',
|
||||
}
|
||||
} catch (err) {
|
||||
return logAndNull(
|
||||
'bank_connection_expiring',
|
||||
companyId,
|
||||
err instanceof Error ? { message: err.message } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* skv_disconnected: a stored Skatteverket connection that can no longer
|
||||
* authenticate. Mirrors the skatteverket extension's /status route exactly
|
||||
* (needs_reconsent flag, or expired with no usable refresh token) and runs
|
||||
* the shared skvStatusNeedsReconnect decision over the row. Connections are
|
||||
* per (user, company), so the predicate needs the caller's user id.
|
||||
* Refresh-token ciphertext is read only for a null check and never returned.
|
||||
*/
|
||||
export async function detectSkvDisconnected(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<Notice | null> {
|
||||
try {
|
||||
if ((process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true') return null
|
||||
const { data, error } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('status, expires_at, refresh_token, refresh_count, last_error_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) return logAndNull('skv_disconnected', companyId, error)
|
||||
if (!data) return null
|
||||
|
||||
const status = (data.status as string | null) ?? 'active'
|
||||
const expiresAt = data.expires_at as string | null
|
||||
const expired = expiresAt !== null && new Date(expiresAt).getTime() < now.getTime()
|
||||
const canRefresh = data.refresh_token !== null && ((data.refresh_count as number | null) ?? 0) < 10
|
||||
const needsReconsent = status === 'needs_reconsent'
|
||||
if (!skvStatusNeedsReconnect({ connected: true, needsReconsent, expired, canRefresh })) {
|
||||
return null
|
||||
}
|
||||
// needs_reconsent rows discriminate on when the terminal error was
|
||||
// detected; refresh-exhausted rows on when the token expired: either way
|
||||
// a NEW failure after a successful re-consent mints a new id.
|
||||
const discriminator = needsReconsent
|
||||
? `needs_reconsent@${(data.last_error_at as string | null) ?? ''}`
|
||||
: `expired@${expiresAt ?? ''}`
|
||||
return {
|
||||
id: `skv_disconnected:${discriminator}`,
|
||||
category: 'skv_disconnected',
|
||||
severity: 'error',
|
||||
messageKey: 'skv_disconnected',
|
||||
actionKey: 'skv_disconnected_action',
|
||||
actionHref: '/settings/tax',
|
||||
}
|
||||
} catch (err) {
|
||||
return logAndNull(
|
||||
'skv_disconnected',
|
||||
companyId,
|
||||
err instanceof Error ? { message: err.message } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Brand names stay untranslated; the sentence around them is localised. */
|
||||
const BACKUP_PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
dropbox: 'Dropbox',
|
||||
}
|
||||
|
||||
interface BackupConnectionValue {
|
||||
status?: 'active' | 'needs_reauth'
|
||||
needs_reauth_at?: string
|
||||
}
|
||||
|
||||
interface BackupScheduleValue {
|
||||
last_auto_sync_status?: 'success' | 'error' | null
|
||||
last_auto_sync_at?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* backup_failing: a connected cloud-backup destination with a dead token or
|
||||
* an errored last auto-sync. Reads the cloud-backup extension's rows in
|
||||
* extension_data directly (core must not import from @/extensions/, so the
|
||||
* keys and value shapes are mirrored here, same as the old
|
||||
* BackupHealthBanner mirrored the status API's shape). Multiple failing
|
||||
* providers fold into ONE notice: a working Drive backup does not make a
|
||||
* broken Dropbox backup acceptable, but two failures must not stack two
|
||||
* lines either.
|
||||
*/
|
||||
export async function detectBackupFailing(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<Notice | null> {
|
||||
try {
|
||||
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return null
|
||||
const { data, error } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.in('key', [
|
||||
'google_drive_connection',
|
||||
'google_drive_schedule',
|
||||
'dropbox_connection',
|
||||
'dropbox_schedule',
|
||||
])
|
||||
if (error) return logAndNull('backup_failing', companyId, error)
|
||||
const byKey = new Map((data ?? []).map((r) => [r.key as string, r.value]))
|
||||
|
||||
const failing: { provider: string; reason: 'reauth' | 'sync_error' }[] = []
|
||||
for (const provider of ['google_drive', 'dropbox']) {
|
||||
const connection = byKey.get(`${provider}_connection`) as BackupConnectionValue | undefined
|
||||
if (!connection) continue
|
||||
const schedule = byKey.get(`${provider}_schedule`) as BackupScheduleValue | undefined
|
||||
if (connection.status === 'needs_reauth') {
|
||||
failing.push({ provider, reason: 'reauth' })
|
||||
} else if (schedule?.last_auto_sync_status === 'error') {
|
||||
failing.push({ provider, reason: 'sync_error' })
|
||||
}
|
||||
}
|
||||
if (failing.length === 0) return null
|
||||
|
||||
const names = failing
|
||||
.map((f) => BACKUP_PROVIDER_LABELS[f.provider] ?? f.provider)
|
||||
.join(' + ')
|
||||
const allNeedReauth = failing.every((f) => f.reason === 'reauth')
|
||||
// Deliberately NO timestamp in the discriminator: the cron re-stamps
|
||||
// last_auto_sync_at on every failed run (and can re-stamp needs_reauth_at
|
||||
// on retries), which would resurrect a dismissed notice daily while the
|
||||
// SAME incident persists. The id is stable per (provider, reason) and the
|
||||
// opposite direction (a NEW failure after a healthy spell must resurface)
|
||||
// is guaranteed by the stale-dismissal reaping in aggregate.ts; contract
|
||||
// in lib/notices/types.ts.
|
||||
const discriminator = failing
|
||||
.map((f) => `${f.provider}=${f.reason}`)
|
||||
.sort()
|
||||
.join(',')
|
||||
return {
|
||||
id: `backup_failing:${discriminator}`,
|
||||
category: 'backup_failing',
|
||||
severity: 'error',
|
||||
messageKey: allNeedReauth ? 'backup_reauth' : 'backup_failing',
|
||||
messageParams: { provider: names },
|
||||
actionKey: 'backup_action',
|
||||
actionHref: '/import#cloud-backup',
|
||||
}
|
||||
} catch (err) {
|
||||
return logAndNull(
|
||||
'backup_failing',
|
||||
companyId,
|
||||
err instanceof Error ? { message: err.message } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* other_account_hint: the wrong-login nudge (#1231). Delegates the detection
|
||||
* to lib/company/other-account-hint (which already fails soft to false); this
|
||||
* wrapper only shapes it as the lowest-priority notice. The id carries no
|
||||
* state discriminator: the condition is "this account is empty while another
|
||||
* holds the bookkeeping", which either holds or stops holding.
|
||||
*/
|
||||
export async function detectOtherAccountHint(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<Notice | null> {
|
||||
try {
|
||||
const show = await shouldShowOtherAccountHint(supabase)
|
||||
if (!show) return null
|
||||
return {
|
||||
id: 'other_account_hint',
|
||||
category: 'other_account_hint',
|
||||
severity: 'warning',
|
||||
messageKey: 'other_account_hint',
|
||||
actionKey: 'other_account_hint_action',
|
||||
// Client surfaces override this with a sign-out handler; the href is
|
||||
// the no-JS fallback destination.
|
||||
actionHref: '/login',
|
||||
}
|
||||
} catch (err) {
|
||||
return logAndNull(
|
||||
'other_account_hint',
|
||||
companyId,
|
||||
err instanceof Error ? { message: err.message } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './categories'
|
||||
export * from './aggregate'
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Pure notice predicates: no queries, no side effects, no server-only
|
||||
* imports. Client pages ('use client') import from THIS file; the server
|
||||
* detection layer (lib/notices/categories.ts, which pulls in server-only
|
||||
* dependencies) builds on the same functions and re-exports them, so every
|
||||
* surface runs one shared decision.
|
||||
*/
|
||||
|
||||
export interface ExpiringBankConnection {
|
||||
id: string
|
||||
bank_name: string
|
||||
days_left: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical "consent expiring soon" day-math over already-fetched
|
||||
* bank_connections rows: within (0, 14] days. Used by the
|
||||
* bank_connection_expiring notice AND by the Hem page's Att göra Bevaka
|
||||
* row, so the two surfaces can never disagree on the threshold.
|
||||
*/
|
||||
export function expiringBankConnectionsFrom(
|
||||
rows: { id: string; bank_name: string | null; consent_expires: string | null }[],
|
||||
now: Date = new Date(),
|
||||
): ExpiringBankConnection[] {
|
||||
const nowMs = now.getTime()
|
||||
const result: ExpiringBankConnection[] = []
|
||||
for (const row of rows) {
|
||||
if (!row.consent_expires) continue
|
||||
const daysLeft = Math.ceil(
|
||||
(new Date(row.consent_expires).getTime() - nowMs) / (1000 * 60 * 60 * 24),
|
||||
)
|
||||
if (daysLeft > 0 && daysLeft <= 14) {
|
||||
result.push({ id: row.id, bank_name: row.bank_name ?? '', days_left: daysLeft })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The skatteverket extension's /status response shape (subset we decide on). */
|
||||
export interface SkvStatusLike {
|
||||
connected?: boolean
|
||||
disabled?: boolean
|
||||
needsReconsent?: boolean
|
||||
expired?: boolean
|
||||
canRefresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical "Skatteverket needs reconnect" predicate over a fetched
|
||||
* /status shape. A connection needs reconnecting when it exists, is not
|
||||
* env-disabled, and either was flagged needs_reconsent by a cron/API call or
|
||||
* has an expired access token with nothing left to refresh with.
|
||||
*/
|
||||
export function skvStatusNeedsReconnect(s: SkvStatusLike): boolean {
|
||||
return Boolean(s.connected && !s.disabled && (s.needsReconsent || (s.expired && !s.canRefresh)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical "this auth failure means reconnect" predicate over a failed
|
||||
* skatteverket API response. 401 covers several distinct auth states (see
|
||||
* handleSkvError in the skatteverket extension): only NOT_CONNECTED means "no
|
||||
* connection exists"; the rest (SESSION_EXPIRED, MISSING_SCOPE, TOKEN_REVOKED,
|
||||
* TOKEN_CORRUPTED, ...) fire while a stored connection exists and mean the
|
||||
* user must reconnect with BankID.
|
||||
*/
|
||||
export function skvAuthErrorNeedsReconnect(
|
||||
status: number,
|
||||
code: string | null | undefined,
|
||||
): boolean {
|
||||
return status === 401 && code !== 'NOT_CONNECTED'
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Notices: the unified degraded-state model (system & integration health).
|
||||
*
|
||||
* One source of truth for "something the user relies on is broken or about
|
||||
* to break", shared by the Hem notice line and (via /api/notices) any client
|
||||
* surface. The sibling of lib/worklist, which owns actionable bookkeeping
|
||||
* work items; notices own connection/health state. Every surface that shows
|
||||
* degraded integration state MUST read it from lib/notices instead of
|
||||
* hand-rolling its own detection, so the same fact is never double-modeled
|
||||
* with diverging thresholds.
|
||||
*
|
||||
* Each category documents its pending ("notice active") and done conditions.
|
||||
*/
|
||||
|
||||
export const NOTICE_CATEGORIES = [
|
||||
/**
|
||||
* A bank connection has already failed.
|
||||
* Pending: bank_connections.status IN ('expired', 'error'): same predicate
|
||||
* as BankSyncStatusChip's "attention" state.
|
||||
* Done: the connection is renewed (status back to 'active') or removed.
|
||||
*/
|
||||
'bank_connection_broken',
|
||||
/**
|
||||
* The Skatteverket connection can no longer authenticate.
|
||||
* Pending: a skatteverket_tokens row exists for (user, company) with
|
||||
* status = 'needs_reconsent', or its access token is expired with
|
||||
* no usable refresh token (refresh_token NULL or refresh_count
|
||||
* >= 10): mirrors the skatteverket extension's /status route.
|
||||
* Done: the user re-consents with BankID (storeTokens resets the row)
|
||||
* or disconnects entirely (no row = not connected = no notice).
|
||||
*/
|
||||
'skv_disconnected',
|
||||
/**
|
||||
* A connected cloud backup is failing (dead token or errored auto-sync).
|
||||
* Pending: a cloud-backup provider connection exists in extension_data
|
||||
* with status = 'needs_reauth', or its schedule's
|
||||
* last_auto_sync_status = 'error'.
|
||||
* Done: reconnect/re-auth, or the next auto-sync succeeds.
|
||||
*/
|
||||
'backup_failing',
|
||||
/**
|
||||
* A PSD2 bank consent expires within 14 days.
|
||||
* Pending: bank_connections.status = 'active' AND consent_expires within
|
||||
* (0, 14] days. A connection that has ALREADY failed is counted
|
||||
* by bank_connection_broken instead (status filter makes the two
|
||||
* categories disjoint: broken supersedes expiring by construction).
|
||||
* Done: the consent is renewed (consent_expires moves out) or the
|
||||
* connection expires (moves to bank_connection_broken).
|
||||
*/
|
||||
'bank_connection_expiring',
|
||||
/**
|
||||
* The signed-in account looks bookkeeping-empty while a same-orgnr company
|
||||
* with real bookkeeping exists in another account (#1231).
|
||||
* Pending: lib/company/other-account-hint shouldShowOtherAccountHint().
|
||||
* Done: the account gets bookkeeping of its own, or the user switches.
|
||||
*/
|
||||
'other_account_hint',
|
||||
] as const
|
||||
|
||||
export type NoticeCategory = (typeof NOTICE_CATEGORIES)[number]
|
||||
|
||||
export type NoticeSeverity = 'error' | 'warning'
|
||||
|
||||
/**
|
||||
* Fixed cross-category priority: surfaces that show a single notice show the
|
||||
* first active one in this order. Tune here, never per call site.
|
||||
*/
|
||||
export const NOTICE_PRIORITY: readonly NoticeCategory[] = [
|
||||
'bank_connection_broken',
|
||||
'skv_disconnected',
|
||||
'backup_failing',
|
||||
'bank_connection_expiring',
|
||||
'other_account_hint',
|
||||
]
|
||||
|
||||
export interface Notice {
|
||||
/**
|
||||
* Stable identity of THIS occurrence: the category plus a state
|
||||
* discriminator (connection id + status, expiry date). A dismissal is
|
||||
* stored against the id, so dismissals silence a state, never a category.
|
||||
*
|
||||
* Two invariants keep dismissals honest, and every id must satisfy both:
|
||||
*
|
||||
* 1. Stable per incident: the discriminator must not embed anything the
|
||||
* system re-stamps while the SAME incident persists (a cron-updated
|
||||
* last-attempt timestamp), or a dismissed notice resurrects daily.
|
||||
* backup_failing therefore discriminates on (provider, reason) only.
|
||||
* 2. Reaped on recovery: the read path (getCompanyNotices) best-effort
|
||||
* deletes the caller's stored dismissals for any category that is
|
||||
* currently healthy, matched by the `category:` id prefix. That is what
|
||||
* makes a NEW failure after a healthy spell resurface even when it mints
|
||||
* the exact same id as the dismissed one: error -> dismiss (hidden while
|
||||
* failing) -> healthy (dismissal reaped on the next read) -> new error
|
||||
* -> visible again. Categories whose id changes per incident anyway
|
||||
* (bank ids embed connection id + status/expiry, skv embeds the
|
||||
* incident's first-error/expiry timestamp) get the same reaping as
|
||||
* hygiene. other_account_hint has no `category:` prefix and is never
|
||||
* reaped: dismissing it silences the condition itself.
|
||||
*
|
||||
* Ids are also bounded: multi-connection discriminators collapse to a
|
||||
* count + 8-char sha256 digest of the sorted parts (categories.ts), so an
|
||||
* id never grows with the number of connections and stays well under the
|
||||
* dismiss schema cap (lib/api/schemas.ts).
|
||||
*/
|
||||
id: string
|
||||
category: NoticeCategory
|
||||
severity: NoticeSeverity
|
||||
/** Key in the `notices` i18n namespace. */
|
||||
messageKey: string
|
||||
/** ICU params for messageKey (bank names, counts, days). */
|
||||
messageParams?: Record<string, string | number>
|
||||
/** Key in the `notices` namespace for the action link label. */
|
||||
actionKey: string
|
||||
/** Where the action link goes (client surfaces may override per category). */
|
||||
actionHref: string
|
||||
}
|
||||
@@ -1044,6 +1044,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
'mailbox OAuth grants (live refresh tokens), not portable. The receipts they find are archived as documents.',
|
||||
mcp_tasks: 'MCP task handles: transient tool-call state with a 1-hour TTL',
|
||||
metered_events: 'billing telemetry',
|
||||
notice_dismissals: 'per-user UI notice dismissal state, not räkenskapsinformation',
|
||||
notification_log: 'notification dedup log',
|
||||
operations: 'staged-operation workflow state',
|
||||
payment_match_log: 'derived matching log',
|
||||
|
||||
+18
-5
@@ -5497,9 +5497,6 @@
|
||||
"ext_cloud_backup_auto_sync_success": "succeeded",
|
||||
"ext_cloud_backup_auto_sync_error": "failed",
|
||||
"ext_cloud_backup_schedule_save_failed": "Could not save schedule",
|
||||
"ext_cloud_backup_banner_reauth": "The {provider} backup is paused: the account needs to be reconnected.",
|
||||
"ext_cloud_backup_banner_failing": "The automatic {provider} backup is failing.",
|
||||
"ext_cloud_backup_banner_action": "Open backup settings",
|
||||
"ext_cloud_backup_verified": "Verified",
|
||||
"ext_cloud_backup_up_to_date": "Already up to date",
|
||||
"ext_cloud_backup_no_changes": "No changes since the last sync.",
|
||||
@@ -6402,6 +6399,24 @@
|
||||
"status_paid_date": "Paid {date}",
|
||||
"status_picker_aria": "Filter by status"
|
||||
},
|
||||
"notices": {
|
||||
"bank_broken_one": "The {bank} bank connection has stopped working and no longer fetches transactions.",
|
||||
"bank_broken_one_unnamed": "A bank connection has stopped working and no longer fetches transactions.",
|
||||
"bank_broken_many": "{count} bank connections have stopped working and no longer fetch transactions.",
|
||||
"bank_broken_action": "Renew the connection",
|
||||
"skv_disconnected": "The Skatteverket connection needs to be renewed. Reconnect with BankID.",
|
||||
"skv_disconnected_action": "Reconnect",
|
||||
"backup_reauth": "The {provider} backup is paused: the account needs to be reconnected.",
|
||||
"backup_failing": "The automatic {provider} backup is failing.",
|
||||
"backup_action": "Open backup settings",
|
||||
"bank_expiring_one": "The bank consent for {bank} expires in {days, plural, =1 {1 day} other {# days}}.",
|
||||
"bank_expiring_many": "{count} bank consents expire soon.",
|
||||
"bank_expiring_action": "Renew the consent",
|
||||
"other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?",
|
||||
"other_account_hint_action": "Switch account",
|
||||
"more_count": "+{count} more",
|
||||
"dismiss": "Hide"
|
||||
},
|
||||
"dashboard": {
|
||||
"skv_promo_title": "Connect Skatteverket",
|
||||
"skv_promo_description": "See your tax account and file VAT and employer declarations directly from here. Connect with BankID in a couple of minutes.",
|
||||
@@ -6471,8 +6486,6 @@
|
||||
"greeting_morning": "Good morning",
|
||||
"greeting_day": "Good day",
|
||||
"greeting_evening": "Good evening",
|
||||
"other_account_hint": "This account is empty, but bookkeeping for the same organisation number exists in another Accounted account. Did you sign in with the wrong login?",
|
||||
"other_account_hint_action": "Switch account",
|
||||
"resume_title": "Continue",
|
||||
"resume_invoice_draft": "Draft: invoice to {customer}",
|
||||
"resume_invoice_unsent": "Send invoice {number}",
|
||||
|
||||
+18
-5
@@ -5497,9 +5497,6 @@
|
||||
"ext_cloud_backup_auto_sync_success": "lyckades",
|
||||
"ext_cloud_backup_auto_sync_error": "misslyckades",
|
||||
"ext_cloud_backup_schedule_save_failed": "Kunde inte spara schema",
|
||||
"ext_cloud_backup_banner_reauth": "Säkerhetskopieringen till {provider} är pausad: kontot behöver kopplas om.",
|
||||
"ext_cloud_backup_banner_failing": "Den automatiska säkerhetskopieringen till {provider} misslyckas.",
|
||||
"ext_cloud_backup_banner_action": "Öppna säkerhetskopiering",
|
||||
"ext_cloud_backup_verified": "Verifierad",
|
||||
"ext_cloud_backup_up_to_date": "Redan uppdaterad",
|
||||
"ext_cloud_backup_no_changes": "Inga ändringar sedan senaste synk.",
|
||||
@@ -6402,6 +6399,24 @@
|
||||
"status_paid_date": "Betald {date}",
|
||||
"status_picker_aria": "Filtrera på status"
|
||||
},
|
||||
"notices": {
|
||||
"bank_broken_one": "Bankkopplingen till {bank} har slutat fungera och hämtar inte längre transaktioner.",
|
||||
"bank_broken_one_unnamed": "En bankkoppling har slutat fungera och hämtar inte längre transaktioner.",
|
||||
"bank_broken_many": "{count} bankkopplingar har slutat fungera och hämtar inte längre transaktioner.",
|
||||
"bank_broken_action": "Förnya kopplingen",
|
||||
"skv_disconnected": "Anslutningen till Skatteverket behöver förnyas. Anslut igen med BankID.",
|
||||
"skv_disconnected_action": "Anslut igen",
|
||||
"backup_reauth": "Säkerhetskopieringen till {provider} är pausad: kontot behöver kopplas om.",
|
||||
"backup_failing": "Den automatiska säkerhetskopieringen till {provider} misslyckas.",
|
||||
"backup_action": "Öppna säkerhetskopiering",
|
||||
"bank_expiring_one": "Banksamtycket för {bank} löper ut om {days, plural, =1 {1 dag} other {# dagar}}.",
|
||||
"bank_expiring_many": "{count} banksamtycken löper ut inom kort.",
|
||||
"bank_expiring_action": "Förnya samtycket",
|
||||
"other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?",
|
||||
"other_account_hint_action": "Byt konto",
|
||||
"more_count": "+{count} till",
|
||||
"dismiss": "Dölj"
|
||||
},
|
||||
"dashboard": {
|
||||
"skv_promo_title": "Koppla Skatteverket",
|
||||
"skv_promo_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Anslut med BankID på ett par minuter.",
|
||||
@@ -6471,8 +6486,6 @@
|
||||
"greeting_morning": "God morgon",
|
||||
"greeting_day": "God dag",
|
||||
"greeting_evening": "God kväll",
|
||||
"other_account_hint": "Det här kontot är tomt, men bokföring för samma organisationsnummer finns i ett annat Accounted-konto. Loggade du in med fel inloggning?",
|
||||
"other_account_hint_action": "Byt konto",
|
||||
"resume_title": "Fortsätt",
|
||||
"resume_invoice_draft": "Utkast: faktura till {customer}",
|
||||
"resume_invoice_unsent": "Skicka faktura {number}",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Per-user dismissals for degraded-state notices (lib/notices).
|
||||
--
|
||||
-- lib/notices aggregates system/integration health (broken bank connections,
|
||||
-- Skatteverket reconnect, failing cloud backups, expiring PSD2 consents,
|
||||
-- wrong-account hint) into priority-ordered notices. A user can dismiss one;
|
||||
-- the dismissal must be server-side and per (company, user) so it works
|
||||
-- cross-device, unlike the localStorage patterns it replaces.
|
||||
--
|
||||
-- notice_id is an opaque text id that embeds a state discriminator
|
||||
-- (connection id + status, consent expiry, error timestamp): a dismissal
|
||||
-- hides exactly the state the user saw, and a NEW failure after a fix mints
|
||||
-- a new id and surfaces again. Rows are therefore never updated in place
|
||||
-- beyond re-stamping dismissed_at on a repeat dismissal (upsert), and stale
|
||||
-- rows for states that no longer occur are harmless.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.notice_dismissals (
|
||||
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users ON DELETE CASCADE,
|
||||
notice_id TEXT NOT NULL,
|
||||
dismissed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (company_id, user_id, notice_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.notice_dismissals ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- All access is scoped to the user's own rows within their companies:
|
||||
-- dismissals are personal (a colleague still sees the notice), so even
|
||||
-- SELECT is bound to auth.uid(), not just company membership.
|
||||
DROP POLICY IF EXISTS "Users see their own notice dismissals" ON public.notice_dismissals;
|
||||
CREATE POLICY "Users see their own notice dismissals"
|
||||
ON public.notice_dismissals FOR SELECT
|
||||
USING (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users insert their own notice dismissals" ON public.notice_dismissals;
|
||||
CREATE POLICY "Users insert their own notice dismissals"
|
||||
ON public.notice_dismissals FOR INSERT
|
||||
WITH CHECK (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
|
||||
-- Upserts re-stamp dismissed_at on conflict, which needs UPDATE.
|
||||
DROP POLICY IF EXISTS "Users update their own notice dismissals" ON public.notice_dismissals;
|
||||
CREATE POLICY "Users update their own notice dismissals"
|
||||
ON public.notice_dismissals FOR UPDATE
|
||||
USING (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
AND user_id = auth.uid()
|
||||
)
|
||||
WITH CHECK (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users delete their own notice dismissals" ON public.notice_dismissals;
|
||||
CREATE POLICY "Users delete their own notice dismissals"
|
||||
ON public.notice_dismissals FOR DELETE
|
||||
USING (
|
||||
company_id IN (SELECT public.user_company_ids())
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* pg-real coverage for 20260819190000_notice_dismissals.sql.
|
||||
*
|
||||
* Every policy on this table binds BOTH company membership and auth.uid(),
|
||||
* which is the property that separates it from the rest of the schema: a
|
||||
* dismissal is personal, so a colleague in the same company must keep seeing
|
||||
* a notice the other member dismissed. Company-only scoping would silence a
|
||||
* degraded-state notice for the whole company the moment one person hid it,
|
||||
* which is exactly the failure the notices work exists to avoid.
|
||||
*
|
||||
* Note on the helper: withUserContext ALWAYS rolls back (tests/pg/setup.ts),
|
||||
* so a write and its verification have to live inside the same callback.
|
||||
* Rows that must survive for a cross-user read are seeded on the pool
|
||||
* connection instead, which bypasses RLS as superuser.
|
||||
*/
|
||||
|
||||
const NOTICE_ID = 'bank_connection_broken:11111111-1111-1111-1111-111111111111=error'
|
||||
|
||||
async function seedDismissal(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
noticeId: string = NOTICE_ID,
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[companyId, userId, noticeId],
|
||||
)
|
||||
}
|
||||
|
||||
async function countRows(companyId: string, userId: string): Promise<number> {
|
||||
const res = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.notice_dismissals
|
||||
WHERE company_id = $1 AND user_id = $2`,
|
||||
[companyId, userId],
|
||||
)
|
||||
return Number(res.rows[0]!.n)
|
||||
}
|
||||
|
||||
describe('notice_dismissals RLS', () => {
|
||||
it('lets a member dismiss a notice for themselves and read it back', async () => {
|
||||
const a = await seedCompany()
|
||||
|
||||
await withUserContext(a.userId, async (client) => {
|
||||
const ins = await client.query(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[a.companyId, a.userId, NOTICE_ID],
|
||||
)
|
||||
expect(ins.rowCount).toBe(1)
|
||||
|
||||
const read = await client.query<{ notice_id: string }>(
|
||||
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
expect(read.rows.map((r) => r.notice_id)).toEqual([NOTICE_ID])
|
||||
})
|
||||
})
|
||||
|
||||
it('re-stamps dismissed_at on a repeat dismissal (the upsert needs UPDATE)', async () => {
|
||||
const a = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id, dismissed_at)
|
||||
VALUES ($1, $2, $3, now() - interval '3 days')`,
|
||||
[a.companyId, a.userId, NOTICE_ID],
|
||||
)
|
||||
|
||||
await withUserContext(a.userId, async (client) => {
|
||||
const res = await client.query<{ dismissed_at: Date }>(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (company_id, user_id, notice_id)
|
||||
DO UPDATE SET dismissed_at = now()
|
||||
RETURNING dismissed_at`,
|
||||
[a.companyId, a.userId, NOTICE_ID],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
// Without the UPDATE policy the ON CONFLICT branch is rejected outright,
|
||||
// so reaching a fresh timestamp is the assertion that pins it.
|
||||
const age = Date.now() - new Date(res.rows[0]!.dismissed_at).getTime()
|
||||
expect(age).toBeLessThan(60_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one member from seeing a colleague dismissal in the same company', async () => {
|
||||
const a = await seedCompany()
|
||||
const colleagueId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
||||
await seedDismissal(a.companyId, a.userId)
|
||||
|
||||
await withUserContext(colleagueId, async (client) => {
|
||||
const res = await client.query(
|
||||
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
expect(res.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
// The owner still sees their own row: the colleague is filtered, not the row.
|
||||
await withUserContext(a.userId, async (client) => {
|
||||
const res = await client.query(
|
||||
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks dismissing on behalf of another user', async () => {
|
||||
const a = await seedCompany()
|
||||
const colleagueId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
||||
|
||||
await withUserContext(a.userId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[a.companyId, colleagueId, NOTICE_ID],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a non-member from dismissing or reading another company notice', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
await seedDismissal(a.companyId, a.userId)
|
||||
|
||||
await withUserContext(b.userId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[a.companyId, b.userId, NOTICE_ID],
|
||||
),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
// A separate context: the rejected INSERT above aborts its transaction,
|
||||
// so the read has to happen in a fresh one.
|
||||
await withUserContext(b.userId, async (client) => {
|
||||
const read = await client.query(
|
||||
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
expect(read.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes only the caller own rows, which is what reaping relies on', async () => {
|
||||
const a = await seedCompany()
|
||||
const colleagueId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
||||
await seedDismissal(a.companyId, a.userId)
|
||||
await seedDismissal(a.companyId, colleagueId)
|
||||
|
||||
await withUserContext(colleagueId, async (client) => {
|
||||
// An unqualified DELETE is still filtered to the caller's rows.
|
||||
const res = await client.query(
|
||||
`DELETE FROM public.notice_dismissals WHERE company_id = $1`,
|
||||
[a.companyId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
// Rolled back by the helper, so both rows are still there for the check
|
||||
// that matters: the DELETE matched exactly one row, not both.
|
||||
expect(await countRows(a.companyId, a.userId)).toBe(1)
|
||||
expect(await countRows(a.companyId, colleagueId)).toBe(1)
|
||||
})
|
||||
|
||||
it('scopes the primary key per (company, user, notice) so the same id can repeat', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const colleagueId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
||||
|
||||
await seedDismissal(a.companyId, a.userId)
|
||||
// Same notice id, different user in the same company: allowed.
|
||||
await expect(seedDismissal(a.companyId, colleagueId)).resolves.not.toThrow()
|
||||
// Same notice id, different company: allowed.
|
||||
await expect(seedDismissal(b.companyId, b.userId)).resolves.not.toThrow()
|
||||
// Exact same triple: rejected by the primary key.
|
||||
await expect(seedDismissal(a.companyId, a.userId)).rejects.toThrow(
|
||||
/notice_dismissals_pkey|duplicate key/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user