* 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>
212 lines
8.8 KiB
TypeScript
212 lines
8.8 KiB
TypeScript
import { redirect } from 'next/navigation'
|
|
import DashboardContent from '@/components/dashboard/DashboardContent'
|
|
import { getWorklistCounts, listSuggestedMatches } from '@/lib/worklist'
|
|
import { listResumeItems } from '@/lib/worklist/resume'
|
|
import { getCompanyNotices } from '@/lib/notices'
|
|
import { expiringBankConnectionsFrom } from '@/lib/notices/categories'
|
|
import { vatDeadlineLine } from '@/lib/onboarding/checklist'
|
|
import type { OnboardingProgress } from '@/types'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getResolvedDashboardAgentProfile,
|
|
} from './request-context'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// Home route = Hem (concept scene 14): greeting + Att göra + Fortsätt.
|
|
// The KPI/revenue/deadline widgets left the page (founder direction,
|
|
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
|
|
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
|
|
// queries are gone and the page got faster.
|
|
|
|
export default async function DashboardPage() {
|
|
const [{ supabase, user }, companyId] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
])
|
|
|
|
if (!user) {
|
|
redirect('/login')
|
|
}
|
|
|
|
if (!companyId) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
// Fetch all data in parallel
|
|
const [
|
|
settingsRes,
|
|
{ count: customerCount },
|
|
{ count: invoiceCount },
|
|
{ count: transactionCount },
|
|
{ data: bankConnections },
|
|
{ count: sieImportCount },
|
|
{ count: skatteverketTokenCount },
|
|
{ count: inboxItemCount },
|
|
{ count: postedEntryCount, error: postedEntryError },
|
|
{ data: nextVatDeadline },
|
|
{ data: profile },
|
|
agentProfile,
|
|
worklist,
|
|
suggestedMatches,
|
|
resumeItems,
|
|
notices,
|
|
] = await Promise.all([
|
|
getDashboardSettings(),
|
|
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'),
|
|
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
|
// Skatteverket connections are per (user, company): filtering on user_id
|
|
// alone made a connection on ANY of the user's companies hide the connect
|
|
// nudge on all of them.
|
|
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
|
|
// Any item ever received in the document inbox (email/WhatsApp/upload)
|
|
// marks the receipts checklist step done: same "has ever done X" shape
|
|
// as the other flags above.
|
|
supabase.from('invoice_inbox_items').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
|
// Posted entries distinguish "brand-new empty ledger" from "all caught
|
|
// up" in the Att göra empty state (hits the partial posted/reversed index).
|
|
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).in('status', ['posted', 'reversed']),
|
|
// Next upcoming momsdeklaration for the checklist's Skatteverket step.
|
|
// Rows are system-generated per company settings; dismissed rows are
|
|
// excluded everywhere deadlines are listed, so here too.
|
|
supabase
|
|
.from('deadlines')
|
|
.select('due_date')
|
|
.eq('company_id', companyId)
|
|
.in('tax_deadline_type', ['moms_monthly', 'moms_quarterly', 'moms_yearly'])
|
|
.eq('is_completed', false)
|
|
.is('dismissed_at', null)
|
|
.gte('due_date', now.toISOString().slice(0, 10))
|
|
.order('due_date', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
// First name for the greeting.
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
|
getResolvedDashboardAgentProfile(),
|
|
// Pending-work counts + suggested matches come from lib/worklist: the
|
|
// same source as the sidebar badges, so the numbers can never diverge.
|
|
getWorklistCounts(supabase, companyId),
|
|
listSuggestedMatches(supabase, companyId, 5),
|
|
// In-progress work for the Fortsätt pane: pure draft-state derivation.
|
|
listResumeItems(supabase, companyId, now),
|
|
// 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":
|
|
// that sent fully onboarded users back to the wizard on a transient query
|
|
// failure (issue #1053). Throw to the error boundary (retryable) and only
|
|
// redirect on a genuinely incomplete or missing settings row.
|
|
const { data: settings, error: settingsError } = settingsRes
|
|
if (settingsError) {
|
|
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
|
|
}
|
|
|
|
// If onboarding is not complete, redirect to onboarding
|
|
if (!settings?.onboarding_complete) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
const agentBuilt = Boolean(agentProfile?.verified_at)
|
|
|
|
const onboardingProgress: OnboardingProgress = {
|
|
hasCustomers: (customerCount || 0) > 0,
|
|
hasInvoices: (invoiceCount || 0) > 0,
|
|
hasBankConnected: (bankConnections?.length || 0) > 0 || (transactionCount || 0) > 0,
|
|
hasSIEImport: (sieImportCount || 0) > 0,
|
|
hasSkatteverketConnected: (skatteverketTokenCount || 0) > 0,
|
|
hasInboxItems: (inboxItemCount || 0) > 0,
|
|
}
|
|
|
|
const vatLine = vatDeadlineLine({
|
|
vatRegistered: settings.vat_registered,
|
|
momsPeriod: settings.moms_period ?? null,
|
|
nextVatDueDate: nextVatDeadline?.due_date ?? null,
|
|
})
|
|
|
|
// "Empty ledger" only matters while the setup checklist is still open; once
|
|
// it is completed or dismissed the ordinary all-clear copy applies. A failed
|
|
// count must NOT read as empty: that would tell a company with real
|
|
// bookkeeping that its ledger is blank, so errors degrade to the normal copy.
|
|
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
|
const emptyLedger = setupOpen && !postedEntryError && (postedEntryCount || 0) === 0
|
|
|
|
// 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
|
|
|
|
// Latest SIE reconciliation-sweep summary across both history sources
|
|
// (PSD2 sync stamps bank_connections.last_sie_sweep; a bank-file import
|
|
// stamps bank_file_imports.sie_sweep). Feeds the checklist's bank step with
|
|
// "X matchade, Y att granska" so a migrator sees the sweep outcome without
|
|
// hunting for it. Best-effort: absent rows just render no note.
|
|
type SieSweepSummaryLite = {
|
|
auto_linked?: number
|
|
suggested?: number
|
|
unmatched?: number
|
|
errors?: number
|
|
ran_at?: string
|
|
}
|
|
const { data: latestFileSweep } = await supabase
|
|
.from('bank_file_imports')
|
|
.select('sie_sweep')
|
|
.eq('company_id', companyId)
|
|
.not('sie_sweep', 'is', null)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
const sweepCandidates: SieSweepSummaryLite[] = [
|
|
...(bankConnections || [])
|
|
.map((c) => c.last_sie_sweep as SieSweepSummaryLite | null)
|
|
.filter((s): s is SieSweepSummaryLite => Boolean(s)),
|
|
...(latestFileSweep?.sie_sweep ? [latestFileSweep.sie_sweep as SieSweepSummaryLite] : []),
|
|
]
|
|
const sieSweep =
|
|
sweepCandidates.length > 0
|
|
? sweepCandidates.reduce((a, b) => ((a.ran_at ?? '') >= (b.ran_at ?? '') ? a : b))
|
|
: null
|
|
|
|
return (
|
|
<DashboardContent
|
|
companyId={companyId}
|
|
agentBuilt={agentBuilt}
|
|
userFirstName={userFirstName}
|
|
expiringBankConnections={expiringBankConnections}
|
|
worklist={worklist}
|
|
suggestedMatches={suggestedMatches}
|
|
resumeItems={resumeItems}
|
|
notices={notices}
|
|
onboardingProgress={onboardingProgress}
|
|
initialSetup={{
|
|
path: settings.initial_setup_path ?? null,
|
|
completedAt: settings.initial_setup_completed_at ?? null,
|
|
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
|
}}
|
|
vatLine={vatLine}
|
|
emptyLedger={emptyLedger}
|
|
sieSweep={
|
|
sieSweep
|
|
? {
|
|
auto_linked: sieSweep.auto_linked ?? 0,
|
|
suggested: sieSweep.suggested ?? 0,
|
|
unmatched: sieSweep.unmatched ?? 0,
|
|
errors: sieSweep.errors ?? 0,
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
)
|
|
}
|