diff --git a/DECISIONS.md b/DECISIONS.md index 43b0b16f..61d6465e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1254,6 +1254,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR. [2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay. [2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published. +[2026-08-26] Reference data (fiscal periods, cash accounts, settings, accounts, dimensions, templates, customers, suppliers, articles) moves behind SWR hooks in lib/reference-data with company-scoped keys, a server seed from the dashboard layout (periods, cash accounts, settings only; the chart of accounts can be hundreds of KB and is warmed lazily instead), explicit invalidateReferenceData() after writes, and a raw-reference-fetch ratchet in check:guards. Chosen over Cache-Control on the API routes (a browser HTTP cache would keep serving stale bodies after a mutate) and over Next 16 cacheComponents/partialPrefetching (38 of 81 dashboard pages are client components whose data lives in client fetches, so an app shell prefetch cannot carry it). Expected side effect: period.list and settings.get volume in the op-completed logs drops toward zero because those reads become browser-side Supabase selects; that is by design, not a broken route. [2026-08-26] AGENTS.md now defers to CLAUDE.md for every shared rule instead of duplicating it: the copy had drifted within weeks (no inline-rättelse path, cookie-first tenancy order, 100+ MCP tools). The four Codex-only constraints Emil added 2026-07-21 (erp-base staging-only migrations, prod writes and main pushes need his explicit approval, no local Docker) were kept in a labelled section rather than removed, because the erp-base Supabase project exists (ref pwxtzglxptnnvjrpixpg) and they describe his environment, not stale product facts; only the project-name spelling was corrected from erpbase. [2026-08-26] MCP tool counts in docs say "150+" (connect-claude, READMEs, rules, registry entry) instead of deriving the number from the tools array: lib/docs/content/connect-claude.ts is core code and core must never import from @/extensions/ (CI builds core with zero extensions), and an exact hand-written number (90+, 100+, 120) had drifted three times already. Regenerate by counting `name: 'gnubok_` in server.ts when the order of magnitude changes. [2026-08-26] API-key scope pickers (settings panel + OAuth consent) render from one SCOPE_GROUPS list in lib/auth/scope-catalog.ts, with MCP tool counts derived from TOOL_SCOPE_MAP at module load: the panel's hand-copied group list had drifted to 24 of 30 scopes (no articles, companies:write or reconciliation, so dashboard-minted keys could not call those tools) and every per-scope count was stale. The catalogue is a separate pure module rather than api-keys.ts itself because the panel is a client component and api-keys.ts imports crypto and the service-role client; a unit test enforces one-group-per-scope so the next scope cannot silently vanish from the pickers. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index f7e3ca60..f8cf29ed 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -14,6 +14,7 @@ import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog' import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' +import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed' import { getCompanyEntitlements } from '@/lib/entitlements/has-capability' import { getBranding } from '@/lib/branding/service' import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types' @@ -148,7 +149,7 @@ export default async function DashboardLayout({ { data: companyRow }, { data: memberRow }, { data: allMemberships }, - { data: settings }, + { data: settings, error: settingsError }, agentProfileIdentity, { data: userProfile }, entitlements, @@ -156,6 +157,8 @@ export default async function DashboardLayout({ { data: userPrefs }, hasWebshop, hasMileageTrips, + { data: seedFiscalPeriods }, + { data: seedCashAccounts }, ] = await Promise.all([ supabase.from('companies').select('*').eq('id', companyId).single(), supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(), @@ -209,6 +212,23 @@ export default async function DashboardLayout({ .eq('company_id', companyId) .limit(1) .then((trips) => (trips.data?.length ?? 0) > 0), + // Reference-data seed (lib/reference-data/seed.ts): the two small lists + // that gate almost every form, fetched once here so the first picker a + // user opens renders populated with zero client round trips. Same + // ordering as period.list and listForCompany so the seed and the client + // refetch agree. The chart of accounts is deliberately NOT seeded: it + // can be hundreds of KB for large companies. + supabase + .from('fiscal_periods') + .select('*') + .eq('company_id', companyId) + .order('period_start', { ascending: false }), + supabase + .from('cash_accounts') + .select('*') + .eq('company_id', companyId) + .order('is_primary', { ascending: false }) + .order('ledger_account', { ascending: true }), ]) // company_id -> current display name for every company the user belongs to. @@ -325,6 +345,12 @@ export default async function DashboardLayout({ return ( + )} + ) } diff --git a/app/(dashboard)/request-context.ts b/app/(dashboard)/request-context.ts index 2d3cfd5c..ed6ce6ed 100644 --- a/app/(dashboard)/request-context.ts +++ b/app/(dashboard)/request-context.ts @@ -35,9 +35,13 @@ export const getDashboardSettings = cache(async () => { ]) if (!companyId) return { data: null, error: null } + // Full row: the layout hands it to the client reference-data cache as the + // seed for useCompanySettings (which reads select('*') itself), so the + // narrow column list this once carried would have been refetched on the + // first mount anyway. The other consumers read a subset of the row. return supabase .from('company_settings') - .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled, mileage_enabled, ore_rounding, initial_setup_path, initial_setup_completed_at, initial_setup_dismissed_at, vat_registered, moms_period') + .select('*') .eq('company_id', companyId) .maybeSingle() }) diff --git a/components/providers/ReferenceDataSeed.tsx b/components/providers/ReferenceDataSeed.tsx new file mode 100644 index 00000000..86ec0287 --- /dev/null +++ b/components/providers/ReferenceDataSeed.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useMemo, type ReactNode } from 'react' +import { SWRConfig } from 'swr' +import { buildReferenceFallback, type ReferenceSeed } from '@/lib/reference-data/seed' + +/** + * Nested SWR config carrying the server-fetched reference data as + * `fallback`. Mounted by app/(dashboard)/layout.tsx inside CompanyProvider; + * SWR merges this with the root SWRProvider so the shared cache, fetcher + * and dedupe stay global while the seed is per company. + */ +export function ReferenceDataSeed({ + companyId, + fiscalPeriods, + cashAccounts, + settings, + children, +}: ReferenceSeed & { companyId: string | null; children: ReactNode }) { + const fallback = useMemo( + () => buildReferenceFallback(companyId, { fiscalPeriods, cashAccounts, settings }), + [companyId, fiscalPeriods, cashAccounts, settings], + ) + return {children} +} diff --git a/lib/reference-data/__tests__/fetchers.test.ts b/lib/reference-data/__tests__/fetchers.test.ts new file mode 100644 index 00000000..500a66dd --- /dev/null +++ b/lib/reference-data/__tests__/fetchers.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const supabaseState = vi.hoisted(() => ({ + result: { data: null as unknown, error: null as unknown }, + calls: [] as Array<{ method: string; args: unknown[] }>, +})) + +vi.mock('@/lib/supabase/client', () => ({ + createClient: () => { + const chain: Record = {} + const proxy: unknown = new Proxy(chain, { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(supabaseState.result) + } + return (...args: unknown[]) => { + supabaseState.calls.push({ method: String(prop), args }) + return proxy + } + }, + }) + return { from: (table: string) => (supabaseState.calls.push({ method: 'from', args: [table] }), proxy) } + }, +})) + +import { + ReferenceFetchError, + fetchAccounts, + fetchArticles, + fetchBookingTemplates, + fetchCashAccounts, + fetchCustomers, + fetchDimensions, + fetchFiscalPeriods, + fetchSuppliers, +} from '../fetchers' + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +describe('browser Supabase fetchers', () => { + beforeEach(() => { + supabaseState.calls = [] + supabaseState.result = { data: [{ id: 'row-1' }], error: null } + }) + + it('fetchFiscalPeriods mirrors period.list: company filter, newest period first', async () => { + const rows = await fetchFiscalPeriods('c1') + expect(rows).toEqual([{ id: 'row-1' }]) + expect(supabaseState.calls).toEqual([ + { method: 'from', args: ['fiscal_periods'] }, + { method: 'select', args: ['*'] }, + { method: 'eq', args: ['company_id', 'c1'] }, + { method: 'order', args: ['period_start', { ascending: false }] }, + ]) + }) + + it('fetchCashAccounts mirrors listForCompany: primary first, then ledger account', async () => { + await fetchCashAccounts('c1') + expect(supabaseState.calls).toEqual([ + { method: 'from', args: ['cash_accounts'] }, + { method: 'select', args: ['*'] }, + { method: 'eq', args: ['company_id', 'c1'] }, + { method: 'order', args: ['is_primary', { ascending: false }] }, + { method: 'order', args: ['ledger_account', { ascending: true }] }, + ]) + }) + + it('throws the Supabase error so SWR surfaces it instead of caching an empty list', async () => { + supabaseState.result = { data: null, error: { message: 'boom' } } + await expect(fetchFiscalPeriods('c1')).rejects.toEqual({ message: 'boom' }) + }) + + it('resolves to an empty list when the query returns no rows', async () => { + supabaseState.result = { data: null, error: null } + expect(await fetchCashAccounts('c1')).toEqual([]) + }) +}) + +describe('API fetchers', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('unwraps { data } and hits the documented URLs', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ data: [{ id: 'x' }] })) + + expect(await fetchAccounts()).toEqual([{ id: 'x' }]) + expect(await fetchAccounts(false)).toEqual([{ id: 'x' }]) + expect(await fetchBookingTemplates()).toEqual([{ id: 'x' }]) + expect(await fetchCustomers()).toEqual([{ id: 'x' }]) + expect(await fetchSuppliers()).toEqual([{ id: 'x' }]) + expect(await fetchArticles()).toEqual([{ id: 'x' }]) + expect(await fetchArticles(true)).toEqual([{ id: 'x' }]) + + expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([ + '/api/bookkeeping/accounts', + '/api/bookkeeping/accounts?active=false', + '/api/settings/booking-templates', + '/api/customers', + '/api/suppliers', + '/api/articles', + '/api/articles?include_inactive=1', + ]) + }) + + it('reads the dimensions route from its `dimensions` field', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ dimensions: [{ id: 'd' }] })) + expect(await fetchDimensions()).toEqual([{ id: 'd' }]) + expect(fetchMock).toHaveBeenCalledWith('/api/dimensions') + }) + + it('returns an empty list when the payload field is missing', async () => { + fetchMock.mockImplementation(async () => jsonResponse({})) + expect(await fetchCustomers()).toEqual([]) + }) + + it('throws a ReferenceFetchError carrying status and body on a non-2xx response', async () => { + fetchMock.mockImplementation(async () => jsonResponse({ error: 'nope' }, 403)) + const err = await fetchSuppliers().catch((e) => e) + expect(err).toBeInstanceOf(ReferenceFetchError) + expect(err.status).toBe(403) + expect(err.body).toEqual({ error: 'nope' }) + }) + + it('still throws with a null body when the error response is not JSON', async () => { + fetchMock.mockImplementation(async () => new Response('gateway', { status: 502 })) + const err = await fetchArticles().catch((e) => e) + expect(err).toBeInstanceOf(ReferenceFetchError) + expect(err.status).toBe(502) + expect(err.body).toBeNull() + }) +}) diff --git a/lib/reference-data/__tests__/keys.test.ts b/lib/reference-data/__tests__/keys.test.ts new file mode 100644 index 00000000..b8043cc3 --- /dev/null +++ b/lib/reference-data/__tests__/keys.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { REFERENCE_KINDS, isReferenceKey, refKeys } from '../keys' + +describe('refKeys', () => { + it('returns null for every builder when there is no active company', () => { + expect(refKeys.companySettings(null)).toBeNull() + expect(refKeys.fiscalPeriods(null)).toBeNull() + expect(refKeys.cashAccounts(null)).toBeNull() + expect(refKeys.accounts(null)).toBeNull() + expect(refKeys.dimensions(null)).toBeNull() + expect(refKeys.bookingTemplates(null)).toBeNull() + expect(refKeys.customers(null)).toBeNull() + expect(refKeys.suppliers(null)).toBeNull() + expect(refKeys.articles(null)).toBeNull() + }) + + it('puts the kind first and the company id second on every key', () => { + const keys = [ + refKeys.companySettings('c1'), + refKeys.fiscalPeriods('c1'), + refKeys.cashAccounts('c1'), + refKeys.accounts('c1'), + refKeys.dimensions('c1'), + refKeys.bookingTemplates('c1'), + refKeys.customers('c1'), + refKeys.suppliers('c1'), + refKeys.articles('c1'), + ] + for (const key of keys) { + expect(key).not.toBeNull() + expect(REFERENCE_KINDS).toContain(key![0]) + expect(key![1]).toBe('c1') + } + expect(new Set(keys.map((k) => k![0])).size).toBe(keys.length) + }) + + it('keeps the legacy company_settings key shape used by useCompanySettings', () => { + expect(refKeys.companySettings('c1')).toEqual(['company_settings', 'c1']) + }) + + it('varies the accounts and articles keys on their filter flag', () => { + expect(refKeys.accounts('c1')).toEqual(['ref:accounts', 'c1', true]) + expect(refKeys.accounts('c1', false)).toEqual(['ref:accounts', 'c1', false]) + expect(refKeys.articles('c1')).toEqual(['ref:articles', 'c1', false]) + expect(refKeys.articles('c1', true)).toEqual(['ref:articles', 'c1', true]) + }) +}) + +describe('isReferenceKey', () => { + it('accepts keys from the builders and rejects everything else', () => { + expect(isReferenceKey(refKeys.fiscalPeriods('c1'))).toBe(true) + expect(isReferenceKey(['company_settings', 'c1'])).toBe(true) + expect(isReferenceKey(['worklist-badges', 'c1'])).toBe(false) + expect(isReferenceKey('/api/settings')).toBe(false) + expect(isReferenceKey(null)).toBe(false) + }) +}) diff --git a/lib/reference-data/__tests__/seed.test.ts b/lib/reference-data/__tests__/seed.test.ts new file mode 100644 index 00000000..eaf5653f --- /dev/null +++ b/lib/reference-data/__tests__/seed.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { unstable_serialize } from 'swr' +import { buildReferenceFallback } from '../seed' +import { refKeys } from '../keys' +import type { CashAccount, CompanySettings, FiscalPeriod } from '@/types' + +const periods = [{ id: 'p1' }] as unknown as FiscalPeriod[] +const cash = [{ id: 'a1' }] as unknown as CashAccount[] +const settings = { company_id: 'c1', company_name: 'Bolaget AB' } as unknown as CompanySettings + +describe('buildReferenceFallback', () => { + it('keys the seed exactly as the hooks key their cache entries', () => { + const fallback = buildReferenceFallback('c1', { fiscalPeriods: periods, cashAccounts: cash, settings }) + expect(Object.keys(fallback).sort()).toEqual( + [ + unstable_serialize(refKeys.fiscalPeriods('c1')), + unstable_serialize(refKeys.cashAccounts('c1')), + unstable_serialize(refKeys.companySettings('c1')), + ].sort(), + ) + expect(fallback[unstable_serialize(refKeys.fiscalPeriods('c1'))]).toBe(periods) + expect(fallback[unstable_serialize(refKeys.cashAccounts('c1'))]).toBe(cash) + expect(fallback[unstable_serialize(refKeys.companySettings('c1'))]).toBe(settings) + }) + + it('seeds a null settings row (no row yet) but omits settings when not fetched', () => { + const withNull = buildReferenceFallback('c1', { fiscalPeriods: [], cashAccounts: [], settings: null }) + expect(withNull[unstable_serialize(refKeys.companySettings('c1'))]).toBeNull() + + const notFetched = buildReferenceFallback('c1', { fiscalPeriods: [], cashAccounts: [] }) + expect(unstable_serialize(refKeys.companySettings('c1')) in notFetched).toBe(false) + }) + + it('seeds nothing without an active company', () => { + expect(buildReferenceFallback(null, { fiscalPeriods: periods, cashAccounts: cash, settings })).toEqual({}) + }) +}) diff --git a/lib/reference-data/fetchers.ts b/lib/reference-data/fetchers.ts new file mode 100644 index 00000000..b2db61e4 --- /dev/null +++ b/lib/reference-data/fetchers.ts @@ -0,0 +1,109 @@ +/** + * Fetchers behind the reference-data hooks. Pure async functions so they can + * be unit-tested without React and reused by `preload()` warm-ups. + * + * Two transports, chosen per data set: + * - Browser Supabase for the trivial RLS-scoped selects (fiscal periods, + * cash accounts). These mirror the corresponding API routes exactly + * (period.list ordering, listForCompany ordering) and save the proxy + + * route-wrapper round trips the API path pays. + * - `/api/...` for lists whose route does real work the client must not + * reimplement: accounts (list_company_accounts RPC with the paged + * fallback), dimensions (ensure_company_dimensions + pagination), + * booking templates (team scoping + last-used ordering), customers + * (personal-number masking), suppliers and articles. + * + * Do not import lib/cash-accounts/service.ts here: it pulls lib/logger and + * the account-sync module into the client bundle. The two order() clauses + * are mirrored instead and pinned by a test. + */ + +import { createClient } from '@/lib/supabase/client' +import type { + Article, + BASAccount, + CashAccount, + Customer, + FiscalPeriod, + Supplier, +} from '@/types' +import type { DimensionDto } from '@/components/dimensions/types' +import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates' + +export class ReferenceFetchError extends Error { + readonly status: number + readonly body: unknown + + constructor(url: string, status: number, body: unknown) { + super(`Reference data request failed: ${status} ${url}`) + this.name = 'ReferenceFetchError' + this.status = status + this.body = body + } +} + +export type BookingTemplateWithUsage = BookingTemplate & { last_used_at: string | null } + +export async function fetchFiscalPeriods(companyId: string): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('fiscal_periods') + .select('*') + .eq('company_id', companyId) + .order('period_start', { ascending: false }) + if (error) throw error + return (data ?? []) as FiscalPeriod[] +} + +export async function fetchCashAccounts(companyId: string): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('cash_accounts') + .select('*') + .eq('company_id', companyId) + .order('is_primary', { ascending: false }) + .order('ledger_account', { ascending: true }) + if (error) throw error + return (data ?? []) as CashAccount[] +} + +async function getJson(url: string, pick: (body: Record) => unknown): Promise { + const res = await fetch(url) + let body: unknown = null + try { + body = await res.json() + } catch { + body = null + } + if (!res.ok) throw new ReferenceFetchError(url, res.status, body) + const picked = pick((body ?? {}) as Record) + return (picked ?? []) as T +} + +export function fetchAccounts(activeOnly = true): Promise { + const url = activeOnly + ? '/api/bookkeeping/accounts' + : '/api/bookkeeping/accounts?active=false' + return getJson(url, (b) => b.data) +} + +export function fetchDimensions(): Promise { + return getJson('/api/dimensions', (b) => b.dimensions) +} + +export function fetchBookingTemplates(): Promise { + return getJson('/api/settings/booking-templates', (b) => b.data) +} + +export function fetchCustomers(): Promise { + return getJson('/api/customers', (b) => b.data) +} + +export function fetchSuppliers(): Promise { + return getJson('/api/suppliers', (b) => b.data) +} + +export function fetchArticles(includeInactive = false): Promise { + const url = includeInactive ? '/api/articles?include_inactive=1' : '/api/articles' + return getJson(url, (b) => b.data) +} diff --git a/lib/reference-data/hooks.ts b/lib/reference-data/hooks.ts new file mode 100644 index 00000000..3d17e523 --- /dev/null +++ b/lib/reference-data/hooks.ts @@ -0,0 +1,161 @@ +'use client' + +/** + * Company-scoped reference data, cached for the session. + * + * Why: forms and pickers used to fetch the same fiscal periods, accounts, + * cash accounts, settings, dimensions and templates on every mount and every + * dialog open (fiscal periods from 47 call sites, settings from 27), each + * request paying the auth proxy + route wrapper before its own query. That + * is the customer-visible "fields load late when clicking around". These + * hooks share one SWR cache entry per company and data set, render from the + * cache (or the server seed, see components/providers/ReferenceDataSeed.tsx) + * on first paint, and revalidate in the background. + * + * Rules: + * - Keys come from lib/reference-data/keys.ts only. + * - After a client write, call invalidateReferenceData() from + * lib/reference-data/invalidate.ts; the dedupe window is bypassed. + * - `revalidateIfStale` stays on so writes made elsewhere (MCP, agent, + * another tab, SIE import) surface within a minute of the next mount. + * - Consumers needing `entity_type` read useCompany().company.entity_type: + * /api/settings falls back to companies.entity_type, this hook does not. + */ + +import { useMemo } from 'react' +import useSWR, { type SWRConfiguration } from 'swr' +import { useCompanyOptional } from '@/contexts/CompanyContext' +import type { + Article, + BASAccount, + CashAccount, + Customer, + FiscalPeriod, + Supplier, +} from '@/types' +import type { DimensionDto } from '@/components/dimensions/types' +import { refKeys } from './keys' +import { + fetchAccounts, + fetchArticles, + fetchBookingTemplates, + fetchCashAccounts, + fetchCustomers, + fetchDimensions, + fetchFiscalPeriods, + fetchSuppliers, + type BookingTemplateWithUsage, +} from './fetchers' + +export { useCompanySettings } from '@/components/settings/useSettings' + +export const REFERENCE_SWR_OPTIONS = { + revalidateOnFocus: false, + revalidateOnReconnect: false, + // At most one background refresh per key per minute; mutate() bypasses it. + dedupingInterval: 60_000, + // Never blank a picker while a refresh is in flight. + keepPreviousData: true, +} satisfies SWRConfiguration + +const EMPTY: never[] = [] + +export interface ReferenceListState { + /** True only while the FIRST load for this key is in flight (no cache, no seed). */ + isLoading: boolean + error: unknown + /** Re-run the fetch for this key now (bypasses the dedupe window). */ + refresh: () => Promise +} + +function useActiveCompanyId(): string | null { + return useCompanyOptional()?.company?.id ?? null +} + +function useReferenceList( + key: K, + fetcher: (key: NonNullable) => Promise, +): ReferenceListState & { items: T[] } { + const { data, error, isLoading, mutate } = useSWR( + key, + fetcher as (key: unknown) => Promise, + REFERENCE_SWR_OPTIONS, + ) + return { + items: data ?? (EMPTY as T[]), + isLoading: key !== null && isLoading, + error, + refresh: mutate, + } +} + +export function useFiscalPeriods(): ReferenceListState & { periods: FiscalPeriod[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.fiscalPeriods(companyId), ([, id]) => + fetchFiscalPeriods(id), + ) + return { periods: items, ...state } +} + +export function useCashAccounts( + options: { enabledOnly?: boolean } = {}, +): ReferenceListState & { cashAccounts: CashAccount[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.cashAccounts(companyId), ([, id]) => + fetchCashAccounts(id), + ) + const enabledOnly = options.enabledOnly ?? false + // One cache entry serves both variants: the enabled filter is cheap and + // keeping a single key means one seed and one invalidation. + const cashAccounts = useMemo( + () => (enabledOnly ? items.filter((a) => a.enabled) : items), + [items, enabledOnly], + ) + return { cashAccounts, ...state } +} + +export function useAccounts(activeOnly = true): ReferenceListState & { accounts: BASAccount[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.accounts(companyId, activeOnly), ([, , active]) => + fetchAccounts(active), + ) + return { accounts: items, ...state } +} + +export function useDimensions(): ReferenceListState & { dimensions: DimensionDto[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.dimensions(companyId), () => fetchDimensions()) + return { dimensions: items, ...state } +} + +export function useBookingTemplates(): ReferenceListState & { templates: BookingTemplateWithUsage[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.bookingTemplates(companyId), () => + fetchBookingTemplates(), + ) + return { templates: items, ...state } +} + +export function useCustomers(): ReferenceListState & { customers: Customer[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.customers(companyId), () => fetchCustomers()) + return { customers: items, ...state } +} + +export function useSuppliers(): ReferenceListState & { suppliers: Supplier[] } { + const companyId = useActiveCompanyId() + const { items, ...state } = useReferenceList(refKeys.suppliers(companyId), () => fetchSuppliers()) + return { suppliers: items, ...state } +} + +export function useArticles( + options: { includeInactive?: boolean } = {}, +): ReferenceListState & { articles: Article[] } { + const companyId = useActiveCompanyId() + const includeInactive = options.includeInactive ?? false + const { items, ...state } = useReferenceList( + refKeys.articles(companyId, includeInactive), + ([, , inactive]) => fetchArticles(inactive), + ) + return { articles: items, ...state } +} diff --git a/lib/reference-data/invalidate.ts b/lib/reference-data/invalidate.ts new file mode 100644 index 00000000..ce3f4b68 --- /dev/null +++ b/lib/reference-data/invalidate.ts @@ -0,0 +1,17 @@ +/** + * Invalidate cached reference data after a client-side write. + * + * Call this from the success path of any dialog or page that creates, + * updates or deletes a period, account, cash account, setting, dimension, + * booking template, customer, supplier or article, so every picker mounted + * anywhere refetches immediately (SWR's global mutate bypasses the dedupe + * window). Client-only: it imports the SWR cache. + */ + +import { mutate } from 'swr' +import { isReferenceKey, type ReferenceKind } from './keys' + +export function invalidateReferenceData(kind: ReferenceKind | ReferenceKind[]): Promise { + const kinds = new Set(Array.isArray(kind) ? kind : [kind]) + return mutate((key) => isReferenceKey(key) && kinds.has(key[0])) +} diff --git a/lib/reference-data/keys.ts b/lib/reference-data/keys.ts new file mode 100644 index 00000000..ba8d4eb6 --- /dev/null +++ b/lib/reference-data/keys.ts @@ -0,0 +1,66 @@ +/** + * SWR cache keys for company-scoped reference data. + * + * One builder per data set is the single source of truth for the hooks + * (lib/reference-data/hooks.ts), the server seed (seed.ts) and the + * invalidation helper (invalidate.ts): a key spelled out anywhere else can + * drift from the seed and silently reintroduce the refetch this layer exists + * to remove. Every key carries the company id in position 1 so a company + * switch can never serve another company's list, and resolves to `null` + * (SWR: do not fetch) when there is no active company. + * + * `company_settings` keeps the shape components/settings/useSettings.ts has + * used since 2026-07-13 so that hook needs no change to be seeded. + */ + +export const REFERENCE_KINDS = [ + 'company_settings', + 'ref:fiscal-periods', + 'ref:cash-accounts', + 'ref:accounts', + 'ref:dimensions', + 'ref:booking-templates', + 'ref:customers', + 'ref:suppliers', + 'ref:articles', +] as const + +export type ReferenceKind = (typeof REFERENCE_KINDS)[number] + +type Key = + | readonly [K, string, ...Rest] + | null + +export const refKeys = { + companySettings: (companyId: string | null): Key<'company_settings'> => + companyId ? (['company_settings', companyId] as const) : null, + fiscalPeriods: (companyId: string | null): Key<'ref:fiscal-periods'> => + companyId ? (['ref:fiscal-periods', companyId] as const) : null, + cashAccounts: (companyId: string | null): Key<'ref:cash-accounts'> => + companyId ? (['ref:cash-accounts', companyId] as const) : null, + accounts: ( + companyId: string | null, + activeOnly = true, + ): Key<'ref:accounts', [boolean]> => + companyId ? (['ref:accounts', companyId, activeOnly] as const) : null, + dimensions: (companyId: string | null): Key<'ref:dimensions'> => + companyId ? (['ref:dimensions', companyId] as const) : null, + bookingTemplates: (companyId: string | null): Key<'ref:booking-templates'> => + companyId ? (['ref:booking-templates', companyId] as const) : null, + customers: (companyId: string | null): Key<'ref:customers'> => + companyId ? (['ref:customers', companyId] as const) : null, + suppliers: (companyId: string | null): Key<'ref:suppliers'> => + companyId ? (['ref:suppliers', companyId] as const) : null, + articles: ( + companyId: string | null, + includeInactive = false, + ): Key<'ref:articles', [boolean]> => + companyId ? (['ref:articles', companyId, includeInactive] as const) : null, +} + +const KIND_SET: ReadonlySet = new Set(REFERENCE_KINDS) + +/** True for any key produced by `refKeys` (used by the invalidation filter). */ +export function isReferenceKey(key: unknown): key is readonly [ReferenceKind, string, ...unknown[]] { + return Array.isArray(key) && typeof key[0] === 'string' && KIND_SET.has(key[0]) +} diff --git a/lib/reference-data/seed.ts b/lib/reference-data/seed.ts new file mode 100644 index 00000000..66b7e590 --- /dev/null +++ b/lib/reference-data/seed.ts @@ -0,0 +1,40 @@ +/** + * Server-to-client seed for the reference-data cache. + * + * The dashboard layout already fetches company settings for its own nav; + * adding the (small) fiscal-period and cash-account lists to that batch and + * handing all three to SWR as `fallback` means the first form a user opens + * renders its period, bank-account and settings-driven fields on the first + * paint with zero client round trips. SWR consults `fallback` only while + * the cache has no entry for the key, so after the first background + * revalidation or any mutate() the live cache wins; a hard reload re-seeds. + * + * Keys are serialized with SWR's own `unstable_serialize` and built from + * `refKeys`, so the seed can never drift from the hooks (pinned by a test). + */ + +import { unstable_serialize } from 'swr' +import type { CashAccount, CompanySettings, FiscalPeriod } from '@/types' +import { refKeys } from './keys' + +export interface ReferenceSeed { + fiscalPeriods: FiscalPeriod[] + cashAccounts: CashAccount[] + /** `undefined` = not fetched (do not seed); `null` = no settings row yet. */ + settings?: CompanySettings | null +} + +export function buildReferenceFallback( + companyId: string | null, + seed: ReferenceSeed, +): Record { + if (!companyId) return {} + const fallback: Record = { + [unstable_serialize(refKeys.fiscalPeriods(companyId))]: seed.fiscalPeriods, + [unstable_serialize(refKeys.cashAccounts(companyId))]: seed.cashAccounts, + } + if (seed.settings !== undefined) { + fallback[unstable_serialize(refKeys.companySettings(companyId))] = seed.settings + } + return fallback +} diff --git a/scripts/checks/__tests__/raw-reference-fetch.test.ts b/scripts/checks/__tests__/raw-reference-fetch.test.ts new file mode 100644 index 00000000..6c1cb70b --- /dev/null +++ b/scripts/checks/__tests__/raw-reference-fetch.test.ts @@ -0,0 +1,114 @@ +/** + * Proof that the raw-reference-fetch ratchet catches the thing and leaves the + * legitimate shapes alone. Offending fixtures live only in these strings and + * in an OS temp directory the end-to-end case creates and deletes. + */ +import { describe, it, expect, afterAll } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { + findRawReferenceFetches, + findRawReferenceFetchesInSource as scan, + isClientSource, +} from '../raw-reference-fetch.mjs' + +const tempDirs: string[] = [] +afterAll(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }) +}) + +const kinds = (source: string) => scan(source).map((f: { kind: string }) => f.kind) + +describe('raw-reference-fetch: GET-shaped API calls', () => { + it('flags a bare GET of each reference path, with or without a query string', () => { + expect(kinds(`const res = await fetch('/api/settings')`)).toEqual(['api']) + expect(kinds('fetch(`/api/bookkeeping/fiscal-periods`)')).toEqual(['api']) + expect(kinds(`fetch('/api/bookkeeping/accounts?active=false')`)).toEqual(['api']) + expect(kinds(`fetch('/api/settings/booking-templates', { signal })`)).toEqual(['api']) + expect(kinds(`fetch('/api/cash-accounts?enabled_only=true', { headers: { a: '1' } })`)).toEqual(['api']) + expect(kinds(`fetch(\n '/api/customers',\n { signal: controller.signal },\n)`)).toEqual(['api']) + }) + + it('ignores writes: they go through the API and then invalidate the cache', () => { + expect(kinds(`fetch('/api/settings', { method: 'PUT', body })`)).toEqual([]) + expect(kinds(`fetch('/api/customers', { method: 'POST', headers: { 'content-type': 'x' }, body })`)).toEqual([]) + expect(kinds("fetch(`/api/settings/booking-templates/${id}/touch`, { method: 'POST' })")).toEqual([]) + expect(kinds(`fetch('/api/articles/' + id, { method: 'DELETE' })`)).toEqual([]) + }) + + it('does not confuse sub-resources or lookalike paths with the reference lists', () => { + expect(kinds("fetch(`/api/settings/booking-templates/${id}`)")).toEqual([]) + expect(kinds(`fetch('/api/settings/banking')`)).toEqual([]) + expect(kinds(`fetch('/api/customers/abc')`)).toEqual([]) + expect(kinds(`fetch('/api/bookkeeping/accounts/bas-catalog')`)).toEqual([]) + }) +}) + +describe('raw-reference-fetch: regex safety', () => { + it('scans a pathological near-miss in linear time (no catastrophic backtracking)', () => { + // A fetch call that never closes, padded with the whitespace/comma mix + // the old `\s*,?\s*\)` tail was ambiguous on. + const source = `fetch('/api/settings'${' ,'.repeat(5000)}${' '.repeat(5000)}X` + const start = performance.now() + expect(scan(source)).toEqual([]) + expect(performance.now() - start).toBeLessThan(200) + }) +}) + +describe('raw-reference-fetch: use-client detection is linear too', () => { + it('handles a long run of unclosed block comments without backtracking', () => { + const source = `${'/* '.repeat(3000)}'use client'\n` + const start = performance.now() + expect(isClientSource(source)).toBe(false) + expect(performance.now() - start).toBeLessThan(200) + }) + + it('still sees the directive behind closed comments', () => { + expect(isClientSource(`/* a */ /* b */\n// c\n'use client'\n`)).toBe(true) + }) +}) + +describe('raw-reference-fetch: browser-side table reads', () => { + const clientRead = `'use client'\nimport x from 'y'\nconst { data } = await supabase.from('fiscal_periods').select('*').eq('company_id', id)` + + it('flags a select on a reference table only in a use-client file', () => { + expect(kinds(clientRead)).toEqual(['table']) + expect(kinds(clientRead.replace(`'use client'\n`, ''))).toEqual([]) + }) + + it('recognises the directive behind leading comments and double quotes', () => { + expect(isClientSource(`// header\n/* block */\n"use client"\n`)).toBe(true) + expect(isClientSource(`import a from 'b'\n'use client'`)).toBe(false) + }) + + it('leaves inserts and updates alone', () => { + expect(kinds(`'use client'\nawait supabase.from('fiscal_periods').insert(row)`)).toEqual([]) + expect(kinds(`'use client'\nawait supabase.from('company_settings').update(patch).eq('company_id', id)`)).toEqual([]) + }) +}) + +describe('raw-reference-fetch: file scan', () => { + it('reports offending files relative to the root and skips sanctioned, api and test files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'raw-ref-')) + tempDirs.push(root) + const write = (rel: string, content: string) => { + const full = path.join(root, rel) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.writeFileSync(full, content) + } + write('components/Bad.tsx', `'use client'\nfetch('/api/settings')`) + write('components/Fine.tsx', `'use client'\nfetch('/api/settings', { method: 'PUT' })`) + write('components/__tests__/Bad.test.tsx', `fetch('/api/settings')`) + write('components/Bad.test.ts', `fetch('/api/settings')`) + write('lib/reference-data/fetchers.ts', `fetch('/api/settings')`) + write('app/api/x/route.ts', `fetch('/api/settings')`) + write('app/(dashboard)/reports/page.tsx', `'use client'\nconst r = await fetch('/api/bookkeeping/fiscal-periods')`) + write('lib/server-thing.ts', `await supabase.from('company_settings').select('*')`) + + expect(findRawReferenceFetches(root)).toEqual([ + 'app/(dashboard)/reports/page.tsx', + 'components/Bad.tsx', + ]) + }) +}) diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 86f00206..6690f40d 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -32,5 +32,65 @@ "components/salary/NewEmployeeDialog.tsx", "components/transactions/InvoiceMatchDialog.tsx" ] + }, + "rawReferenceFetch": { + "count": 55, + "files": [ + "app/(dashboard)/assets/[id]/dispose/page.tsx", + "app/(dashboard)/bookkeeping/year-end/page.tsx", + "app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx", + "app/(dashboard)/customers/page.tsx", + "app/(dashboard)/import/page.tsx", + "app/(dashboard)/invoices/[id]/page.tsx", + "app/(dashboard)/invoices/page.tsx", + "app/(dashboard)/pending/page.tsx", + "app/(dashboard)/salary/employees/[id]/page.tsx", + "app/(dashboard)/salary/runs/[id]/page.tsx", + "app/(dashboard)/supplier-invoices/[id]/page.tsx", + "app/(dashboard)/transactions/page.tsx", + "components/articles/ArticleForm.tsx", + "components/bookkeeping/BookingTemplatePicker.tsx", + "components/bookkeeping/ChartOfAccounts.tsx", + "components/bookkeeping/ChartOfAccountsManager.tsx", + "components/bookkeeping/CorrectionEntryDialog.tsx", + "components/bookkeeping/EditAccountDialog.tsx", + "components/bookkeeping/JournalEntryForm.tsx", + "components/bookkeeping/JournalEntryList.tsx", + "components/bookkeeping/StrikeLinesDialog.tsx", + "components/bookkeeping/TemplateBookDialog.tsx", + "components/common/CashAccountSelector.tsx", + "components/common/FiscalYearSelector.tsx", + "components/common/FyPicker.tsx", + "components/dimensions/types.ts", + "components/extensions/general/ArcimMigrationWorkspace.tsx", + "components/extensions/general/BookDirectlyDialog.tsx", + "components/extensions/general/InvoiceInboxWorkspace.tsx", + "components/extensions/general/TicWorkspace.tsx", + "components/import/BankFileConfirmStep.tsx", + "components/import/FiscalYearGapNotice.tsx", + "components/import/ImportReviewStep.tsx", + "components/import/OpeningBalancePeriodStep.tsx", + "components/invoices/InvoiceEditor.tsx", + "components/invoices/PaymentBookingDialog.tsx", + "components/invoices/SendInvoiceDialog.tsx", + "components/pending-operations/use-account-names.ts", + "components/reports/SkatteverketPanel.tsx", + "components/reports/views/index.tsx", + "components/salary/NewEmployeeDialog.tsx", + "components/settings/BookingTemplatesPanel.tsx", + "components/settings/FiscalPeriodEditor.tsx", + "components/settings/FiscalYearsManager.tsx", + "components/settings/InvoicePaymentAccountsSettings.tsx", + "components/supplier-invoices/use-supplier-invoice-data.ts", + "components/suppliers/SupplierForm.tsx", + "components/transactions/BulkBookDialog.tsx", + "components/transactions/DuplicateBookingDialog.tsx", + "components/transactions/InvoiceMatchDialog.tsx", + "components/transactions/MatchVoucherDialog.tsx", + "components/transactions/QuickReviewDialog.tsx", + "components/transactions/TemplatePicker.tsx", + "components/transactions/TransactionBookingDialog.tsx", + "extensions/general/enable-banking/components/AccountPickerDialog.tsx" + ] } } diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index c158a9d5..addecfea 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -121,6 +121,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { findSekLabelledFxAmounts } from './format-currency-sek-label.mjs' +import { findRawReferenceFetches } from './raw-reference-fetch.mjs' import { findExtensionRouteFindings, UNGATED_EXTENSION_ROUTES, @@ -1016,6 +1017,7 @@ const current = { foldedPublicFlags: findFoldedPublicFlags(), dialogOverflowRisk: findDialogOverflowRisks(), directAiClients: findDirectAiClients(), + rawReferenceFetch: findRawReferenceFetches(ROOT), } const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort() @@ -1037,6 +1039,10 @@ if (isUpdate) { count: dialogOverflowFiles.length, files: dialogOverflowFiles, }, + rawReferenceFetch: { + count: current.rawReferenceFetch.length, + files: current.rawReferenceFetch, + }, } fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n') console.log( @@ -1275,6 +1281,31 @@ if (newLedgerScans.length) { ) } +// 1d. raw-reference-fetch: per-file ratchet. A file outside the baseline set +// that fetches reference data raw (see raw-reference-fetch.mjs) is a NEW +// violation; grandfathered files stay until they move to the hooks. Once the +// baseline reaches 0, delete the entry so any new site is a hard failure. +const rawRefBaseline = new Set(baseline.rawReferenceFetch?.files ?? []) +const newRawRefs = current.rawReferenceFetch.filter((f) => !rawRefBaseline.has(f)) +const fixedRawRefs = (baseline.rawReferenceFetch?.files ?? []).filter( + (f) => !current.rawReferenceFetch.includes(f), +) +if (newRawRefs.length) { + failed = true + console.error( + `\n✗ raw-reference-fetch: ${newRawRefs.length} file(s) fetch reference data raw ` + + `(fiscal periods, settings, accounts, cash accounts, dimensions, templates, customers, suppliers, articles):`, + ) + newRawRefs.forEach((f) => console.error(` ${f}`)) + console.error( + ' → read it through the hooks in lib/reference-data/hooks.ts (useFiscalPeriods, useAccounts,\n' + + ' useCashAccounts, useCompanySettings, useDimensions, useBookingTemplates, useCustomers,\n' + + ' useSuppliers, useArticles) and call invalidateReferenceData() after writes. Those hooks\n' + + ' share one session cache and are seeded by the dashboard layout, so the fields render\n' + + ' on first paint instead of after another round trip.', + ) +} + // 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside // the baseline set is a NEW violation. Grandfathered files stay until fixed. const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? []) @@ -1315,6 +1346,7 @@ if ( fixedAuthFiles.length || fixedLedgerScans.length || fixedDialogOverflow.length || + fixedRawRefs.length || current.naiveOreRound < baseline.naiveOreRound.count ) { console.log('\n✓ Progress since baseline:') @@ -1323,6 +1355,8 @@ if ( console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`) if (fixedDialogOverflow.length) console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`) + if (fixedRawRefs.length) + console.log(` raw-reference-fetch: -${fixedRawRefs.length} file(s)`) if (current.naiveOreRound < baseline.naiveOreRound.count) console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`) console.log(' Run with --update to ratchet the baseline down and lock in the gains.') @@ -1347,5 +1381,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, ) diff --git a/scripts/checks/raw-reference-fetch.mjs b/scripts/checks/raw-reference-fetch.mjs new file mode 100644 index 00000000..661342a8 --- /dev/null +++ b/scripts/checks/raw-reference-fetch.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Guard: reference data fetched outside lib/reference-data. + * + * Fiscal periods, company settings, the chart of accounts, cash accounts, + * dimensions, booking templates, customers, suppliers and articles are + * session-cached behind the hooks in lib/reference-data/hooks.ts (seeded + * from the dashboard layout, invalidated after writes). Before that layer + * existed the same lists were fetched raw from 47 / 27 / 14 / 8 / 12 / 5 + * independent call sites, uncached, on every mount and every dialog open, + * which is what a customer described as "it takes time before all fields + * load when clicking around" (2026-08-26). This check keeps that number + * going down: an existing raw call site is grandfathered in the baseline, + * a NEW one fails CI. + * + * Two shapes are flagged: + * 1. A GET-shaped `fetch('/api/')` anywhere under app/, + * components/, extensions/ or lib/ (a relative URL is client code by + * definition). Writes (`method: 'POST' | 'PUT' | ...`) are fine: they + * go through the API and then call invalidateReferenceData(). + * 2. A browser-side `.from('').select(` in a file that + * carries the 'use client' directive. Server code reading those tables + * is legitimate and is not scanned. + * + * Sanctioned (RAW_REFERENCE_SANCTIONED): the fetchers themselves, the + * pre-existing SWR settings hook, and the static BAS catalog loader (a + * different, module-cached data set). + */ + +import fs from 'node:fs' +import path from 'node:path' + +export const REFERENCE_API_PATHS = [ + 'bookkeeping/fiscal-periods', + 'settings/booking-templates', + 'settings', + 'bookkeeping/accounts', + 'cash-accounts', + 'dimensions', + 'customers', + 'suppliers', + 'articles', +] + +export const REFERENCE_TABLES = [ + 'fiscal_periods', + 'company_settings', + 'chart_of_accounts', + 'cash_accounts', +] + +export const RAW_REFERENCE_SANCTIONED = new Set([ + 'lib/reference-data/fetchers.ts', + 'components/settings/useSettings.ts', + 'lib/bookkeeping/bas-catalog-client.ts', +]) + +const SCAN_DIRS = ['app/(dashboard)', 'components', 'extensions', 'lib'] +const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '__tests__']) + +const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + +// fetch(`/api/settings`), fetch('/api/settings?x=1'), fetch('/api/settings', { signal }) +// The init object may nest one level ({ headers: { ... } }); its text is +// captured so a write method can be excluded. Trailing commas (prettier's +// multi-line call style) are tolerated before the closing paren. Every +// optional whitespace run is anchored by a literal (`,` or `)`) so the +// pattern has no ambiguous backtracking (CodeQL js/redos on the earlier +// `\s*,?\s*\)` shape). +const REFERENCE_URL = String.raw`[\x60'"]/api/(?:${REFERENCE_API_PATHS.map(escape).join('|')})(?:\?[^\x60'"]*)?[\x60'"]` +const INIT_OBJECT = String.raw`\{(?:[^{}]|\{[^{}]*\})*\}` +const API_FETCH_RE = new RegExp( + String.raw`fetch\(\s*${REFERENCE_URL}(?:\s*,\s*(${INIT_OBJECT}))?(?:\s*,)?\s*\)`, + 'g', +) +const WRITE_METHOD_RE = /method\s*:\s*[\x60'"](?!GET\b)/i + +const TABLE_SELECT_RE = new RegExp( + String.raw`\.from\(\s*['"](?:${REFERENCE_TABLES.map(escape).join('|')})['"]\s*\)\s*\.\s*select\(`, + 'g', +) + +// Leading whitespace and comments before the directive. A block comment body +// is `(?:[^*]|\*(?!\/))*`, which cannot cross a `*/`, so each iteration of the +// outer star has exactly one parse: the lazy `[\s\S]*?` form let an unclosed +// `/*` be re-split at every later `/*` (CodeQL js/redos). +// Single-character whitespace alternative (not \s+): a `+` inside the outer +// `*` is a nested quantifier on the same character (CodeQL js/redos). +const USE_CLIENT_RE = /^(?:\s|\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/)*['"]use client['"]/ + +export function isClientSource(source) { + return USE_CLIENT_RE.test(source) +} + +/** + * Findings for one file's source: `{ kind: 'api' | 'table', index }`. + * Exported for the unit test; the file-level scan below uses it. + */ +export function findRawReferenceFetchesInSource(source) { + const findings = [] + for (const match of source.matchAll(API_FETCH_RE)) { + const init = match[1] + if (init && WRITE_METHOD_RE.test(init)) continue + findings.push({ kind: 'api', index: match.index ?? 0 }) + } + if (isClientSource(source)) { + for (const match of source.matchAll(TABLE_SELECT_RE)) { + findings.push({ kind: 'table', index: match.index ?? 0 }) + } + } + return findings +} + +function walk(dir, out) { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (IGNORE_DIRS.has(entry.name)) continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full, out) + else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full) + } +} + +/** Sorted repo-relative paths of files with at least one raw reference fetch. */ +export function findRawReferenceFetches(root) { + const files = [] + for (const dir of SCAN_DIRS) walk(path.join(root, dir), files) + const offenders = [] + for (const file of files) { + const rel = path.relative(root, file).split(path.sep).join('/') + if (rel.startsWith('app/api/') || RAW_REFERENCE_SANCTIONED.has(rel)) continue + const source = fs.readFileSync(file, 'utf8') + if (findRawReferenceFetchesInSource(source).length) offenders.push(rel) + } + return [...new Set(offenders)].sort() +}