Files
accounted/lib/notices/aggregate.ts
T
Jakob Wennberg a8980a3a41 perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited
for the slowest worklist scan, and the request also paid a sequential
bank_file_imports read after the batch, a second scan of the suggested
matches (getWorklistCounts counted the same 200 rows the pane listed) and
an awaited stale-dismissal delete on the read path.

- page.tsx awaits only what the greeting shell and the redirects need
  (settings, profile, agent profile, the Skatteverket flag); the notice
  line, the setup checklist and the Att göra + Fortsätt panes are async
  server components behind their own Suspense (hem-sections.tsx). RSC
  streaming applies to client navigations too, so the greeting paints
  first on every visit and each block fills in as its queries land.
- DashboardContent becomes the shell with three slots; HemNotices keeps
  the one client-side action (the wrong-account sign-out); HemSkeletons
  are the two fallbacks.
- getWorklistCounts accepts the suggested matches the caller is already
  fetching (a promise, so it stays parallel); listSuggestedMatches runs
  once at the scan cap and the pane shows the first five.
- countInboxDocuments runs its id chunks in one wave instead of N
  sequential round trips.
- getCompanyNotices takes deferReap; Hem passes Next's after() so the
  stale-dismissal delete runs after the response.
- bank_file_imports joins the checklist section's batch.

Tests: worklist aggregate (precomputed matches skip the rescan), notices
aggregate (deferReap receives the reap; the delete does not run inline
and runs when the task does).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:02:09 +02:00

137 lines
4.9 KiB
TypeScript

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,
detectSkvUnexplained,
} from './categories'
const log = createLogger('notices')
export interface GetCompanyNoticesOptions {
/**
* Run the stale-dismissal reap after the response instead of on the read
* path (Hem passes Next's `after`). The reap is hygiene: awaiting it made
* every Hem render wait for a delete nobody sees.
*/
deferReap?: (task: () => Promise<void>) => void
/** 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(), deferReap }: 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),
detectSkvUnexplained(supabase, companyId),
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)
})
if (deferReap) deferReap(() => reapStaleDismissals(supabase, companyId, userId, staleIds))
else 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()
}
}