Files
accounted/lib/reference-data/fetchers.ts
T
Mattsson 57d4359d1a feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates

Users cannot delete or hide the 26 standard konteringspaket, which clutter
the settings panel and every template picker. Deletion stays off the table
(shared global rows); instead a company can now hide individual system
templates for itself only.

- New booking_template_hidden table (insert=hide, delete=unhide), RLS gated
  on active company + write role; nothing hidden by default
- POST/DELETE /api/settings/booking-templates/[id]/hide (system templates
  only; company/team templates keep their real delete path)
- List route decorates rows with per-company is_hidden; pickers filter them
  out; the settings panel shows hidden ones in a collapsed restore section
  so hiding is never silent
- Classified in full-archive-export exclusions (UI preference, not
  rakenskapsinformation)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL

* fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book

Skeptic + CodeRabbit findings on #2004, one pass:

- hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no
  UPDATE policy on purpose, so the DO UPDATE conflict arm turned a
  concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape
- bth_insert policy additionally requires the referenced template to be an
  active system template (migration is unmerged, edited in place); negative
  pg test for company templates
- BulkBookDialog excludes templates hidden by the company (was reading the
  table directly and ignoring hides)
- panel shows the failure toast when the hide/unhide fetch itself rejects
- picker category chips built from the hidden-filtered list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:12:47 +02:00

118 lines
4.0 KiB
TypeScript

/**
* 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,
BookingTemplateLibrary,
CashAccount,
Customer,
FiscalPeriod,
Supplier,
} from '@/types'
import type { DimensionDto } from '@/components/dimensions/types'
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
}
}
/**
* A booking_templates row as the list route returns it, with its last-used
* stamp and the per-company hidden flag. `is_hidden` templates stay in the
* payload so the settings panel can offer restore; pickers filter them out.
*/
export type BookingTemplateWithUsage = BookingTemplateLibrary & {
last_used_at: string | null
is_hidden: boolean
}
export async function fetchFiscalPeriods(companyId: string): Promise<FiscalPeriod[]> {
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<CashAccount[]> {
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<T>(url: string, pick: (body: Record<string, unknown>) => unknown): Promise<T> {
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<string, unknown>)
return (picked ?? []) as T
}
export function fetchAccounts(activeOnly = true): Promise<BASAccount[]> {
const url = activeOnly
? '/api/bookkeeping/accounts'
: '/api/bookkeeping/accounts?active=false'
return getJson<BASAccount[]>(url, (b) => b.data)
}
export function fetchDimensions(): Promise<DimensionDto[]> {
return getJson<DimensionDto[]>('/api/dimensions', (b) => b.dimensions)
}
export function fetchBookingTemplates(): Promise<BookingTemplateWithUsage[]> {
return getJson<BookingTemplateWithUsage[]>('/api/settings/booking-templates', (b) => b.data)
}
export function fetchCustomers(): Promise<Customer[]> {
return getJson<Customer[]>('/api/customers', (b) => b.data)
}
export function fetchSuppliers(): Promise<Supplier[]> {
return getJson<Supplier[]>('/api/suppliers', (b) => b.data)
}
export function fetchArticles(includeInactive = false): Promise<Article[]> {
const url = includeInactive ? '/api/articles?include_inactive=1' : '/api/articles'
return getJson<Article[]>(url, (b) => b.data)
}