diff --git a/app/api/settings/booking-templates/sync/cron/__tests__/route.test.ts b/app/api/settings/booking-templates/sync/cron/__tests__/route.test.ts new file mode 100644 index 00000000..bcd897b3 --- /dev/null +++ b/app/api/settings/booking-templates/sync/cron/__tests__/route.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for the pack sync cron. + * + * The route itself is thin; what matters is that it fails CLOSED. A cron that + * reports success while the catalogue failed to load would let a broken deploy + * sit unnoticed until someone opened the template picker. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: vi.fn(() => null), +})) + +const syncSystemPacks = vi.fn() +vi.mock('@/lib/packs/sync', () => ({ + syncSystemPacks: (...args: unknown[]) => syncSystemPacks(...args), +})) + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(() => ({ __service: true })), +})) + +const OK_RESULT = { + inserted: ['a'], updated: ['b'], unchanged: ['c', 'd'], retired: [], errors: [], dryRun: false, +} + +async function callRoute() { + const { GET } = await import('../route') + return GET(new Request('https://example.test/api/settings/booking-templates/sync/cron') as never, {} as never) +} + +beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key' +}) + +describe('GET /api/settings/booking-templates/sync/cron', () => { + it('returns the sync counts on success', async () => { + syncSystemPacks.mockResolvedValueOnce(OK_RESULT) + + const res = await callRoute() + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.data).toMatchObject({ inserted: 1, updated: 1, unchanged: 2, retired: 0 }) + }) + + it('names the retired slugs, so a retirement is never silent', async () => { + syncSystemPacks.mockResolvedValueOnce({ ...OK_RESULT, retired: ['gammal-mall'] }) + + const body = await (await callRoute()).json() + expect(body.data.retired_slugs).toEqual(['gammal-mall']) + }) + + it('fails when the catalogue is invalid instead of reporting success', async () => { + // syncSystemPacks writes nothing in this case, so the previous catalogue + // stands. The cron must still go red: a silent 200 would hide a bad deploy. + syncSystemPacks.mockResolvedValueOnce({ + ...OK_RESULT, + inserted: [], updated: [], unchanged: [], + errors: ['packs/x.yaml: meta.slug: slug must be lowercase kebab-case'], + }) + + const res = await callRoute() + expect(res.status).toBeGreaterThanOrEqual(500) + }) + + it('fails when Supabase configuration is missing', async () => { + delete process.env.SUPABASE_SERVICE_ROLE_KEY + + const res = await callRoute() + expect(res.status).toBeGreaterThanOrEqual(500) + expect(syncSystemPacks).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/settings/booking-templates/sync/cron/route.ts b/app/api/settings/booking-templates/sync/cron/route.ts new file mode 100644 index 00000000..65fb75db --- /dev/null +++ b/app/api/settings/booking-templates/sync/cron/route.ts @@ -0,0 +1,67 @@ +import { createClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { syncSystemPacks } from '@/lib/packs/sync' + +/** + * GET /api/settings/booking-templates/sync/cron + * + * Reconciles the system booking templates in the database with `packs/*.yaml` + * (schedule in vercel.json). The packs ship with the deploy; this is what makes + * the deployed catalogue the one companies actually see, so editing a template + * is a file change plus a deploy rather than a migration. + * + * Idempotent by construction: a database already matching the packs performs + * zero writes, so running it more often than needed costs one SELECT. + * + * Deliberately a cron rather than boot-time work: a sync on every cold start + * would have every serverless instance racing to write the same rows, and a + * bad catalogue would be re-applied continuously instead of once a day where + * it is visible in the logs. + * + * Service-role client, no company context: system templates belong to no + * company and RLS forbids writing them from a user session (btl_insert / + * btl_update both exclude is_system rows). + */ + +// The catalogue is small (tens of rows); this never approaches the budget. +export const maxDuration = 60 + +export const GET = withCronContext('cron.booking_templates_sync', async (_request, ctx) => { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (!supabaseUrl || !supabaseServiceKey) { + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Missing Supabase configuration' }, + }) + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }) + + const result = await syncSystemPacks(supabase) + + if (result.errors.length) { + // A catalogue that fails to load is a deploy problem, not a data problem: + // syncSystemPacks writes nothing in that case, so the previous state stands. + ctx.log.error('pack sync aborted: catalogue invalid', { errors: result.errors }) + return errorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Pack catalogue invalid', errors: result.errors }, + }) + } + + return NextResponse.json({ + data: { + inserted: result.inserted.length, + updated: result.updated.length, + unchanged: result.unchanged.length, + retired: result.retired.length, + retired_slugs: result.retired, + }, + }) +}) diff --git a/docker/crontab.hosted b/docker/crontab.hosted index d9eb84b2..22f5f31d 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -25,6 +25,7 @@ 0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron 0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron +30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron 0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron 0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron 30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index 44850aa3..3d1067b4 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -25,6 +25,7 @@ 0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron 0 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/recurring/cron +30 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/settings/booking-templates/sync/cron 0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron 0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron 30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron diff --git a/lib/packs/__tests__/sync.test.ts b/lib/packs/__tests__/sync.test.ts new file mode 100644 index 00000000..78d13ad8 --- /dev/null +++ b/lib/packs/__tests__/sync.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { syncSystemPacks } from '@/lib/packs/sync' + +/** + * The sync's dangerous paths are the ones that WRITE. Each test below pins a + * case where a naive implementation would quietly damage the catalogue: + * retiring everything on a bad deploy, wiping usage history by deleting, or + * writing a half-read catalogue into the database. + */ + +interface Row { + id: string + pack_slug: string | null + name: string + description: string + category: string + entity_type: string + lines: unknown + is_active: boolean +} + +function mockSupabase(rows: Row[]) { + const inserts: unknown[] = [] + const updates: Array<{ id: string; patch: Record }> = [] + const deletes: string[] = [] + + const client = { + from: () => ({ + select: () => ({ + eq: () => Promise.resolve({ data: rows, error: null }), + }), + insert: (payload: unknown) => { + inserts.push(payload) + return Promise.resolve({ error: null }) + }, + update: (patch: Record) => ({ + eq: (_col: string, id: string) => { + updates.push({ id, patch }) + return Promise.resolve({ error: null }) + }, + }), + delete: () => ({ + eq: (_col: string, id: string) => { + deletes.push(id) + return Promise.resolve({ error: null }) + }, + }), + }), + } + return { client, inserts, updates, deletes } +} + +beforeEach(() => vi.clearAllMocks()) + +describe('syncSystemPacks', () => { + it('inserts packs that have no row yet', async () => { + const { client, inserts } = mockSupabase([]) + const r = await syncSystemPacks(client as never, { dryRun: true }) + + expect(r.errors).toEqual([]) + expect(r.inserted.length).toBeGreaterThan(0) + expect(r.updated).toEqual([]) + // dryRun: nothing written. + expect(inserts).toEqual([]) + }) + + it('is idempotent: a database already matching the packs produces no writes', async () => { + const { client: probe } = mockSupabase([]) + const planned = await syncSystemPacks(probe as never, { dryRun: true }) + + // Build rows that exactly match what the packs want. + const { loadPacks, packToLibraryRow } = await import('@/lib/packs/load') + const { packs } = loadPacks() + const rows: Row[] = packs.map((p, i) => ({ + id: `id-${i}`, + pack_slug: p.pack.meta.slug, + ...packToLibraryRow(p.pack), + is_active: true, + })) as unknown as Row[] + + const { client, inserts, updates } = mockSupabase(rows) + const r = await syncSystemPacks(client as never) + + expect(r.unchanged).toHaveLength(planned.inserted.length) + expect(r.inserted).toEqual([]) + expect(r.updated).toEqual([]) + expect(r.retired).toEqual([]) + expect(inserts).toEqual([]) + expect(updates).toEqual([]) + }) + + it('updates a row whose content drifted from its pack', async () => { + const { loadPacks, packToLibraryRow } = await import('@/lib/packs/load') + const { packs } = loadPacks() + const rows: Row[] = packs.map((p, i) => ({ + id: `id-${i}`, + pack_slug: p.pack.meta.slug, + ...packToLibraryRow(p.pack), + is_active: true, + })) as unknown as Row[] + rows[0].description = 'stale text that no pack says' + + const { client, updates } = mockSupabase(rows) + const r = await syncSystemPacks(client as never) + + expect(r.updated).toEqual([rows[0].pack_slug]) + expect(updates).toHaveLength(1) + expect(updates[0].id).toBe('id-0') + }) + + it('RETIRES an orphan rather than deleting it, to protect usage history', async () => { + const { loadPacks, packToLibraryRow } = await import('@/lib/packs/load') + const { packs } = loadPacks() + const rows: Row[] = packs.map((p, i) => ({ + id: `id-${i}`, + pack_slug: p.pack.meta.slug, + ...packToLibraryRow(p.pack), + is_active: true, + })) as unknown as Row[] + rows.push({ + id: 'orphan-1', + pack_slug: 'a-pack-that-no-longer-exists', + name: 'Gammal mall', description: '', category: 'other', entity_type: 'all', + lines: [], is_active: true, + }) + + const { client, updates, deletes } = mockSupabase(rows) + const r = await syncSystemPacks(client as never) + + expect(r.retired).toEqual(['a-pack-that-no-longer-exists']) + // booking_template_usage.template_id is ON DELETE CASCADE: deleting would + // wipe every company's usage record for the template. + expect(deletes).toEqual([]) + expect(updates).toEqual([{ id: 'orphan-1', patch: { is_active: false } }]) + }) + + it('does not re-retire an already inactive orphan', async () => { + const { client, updates } = mockSupabase([ + { + id: 'orphan-1', pack_slug: 'gone', name: 'x', description: '', category: 'other', + entity_type: 'all', lines: [], is_active: false, + }, + ]) + const r = await syncSystemPacks(client as never, { dryRun: true }) + expect(r.retired).toEqual([]) + expect(updates).toEqual([]) + }) + + it('refuses to write anything when the catalogue is empty', async () => { + // A packs/ directory that did not make it into the deploy must never be + // read as "retire every system template". + const { client, updates, inserts } = mockSupabase([]) + const r = await syncSystemPacks(client as never, { root: '/nonexistent-root' }) + + expect(r.errors[0]).toMatch(/refusing to retire/) + expect(r.inserted).toEqual([]) + expect(updates).toEqual([]) + expect(inserts).toEqual([]) + }) + + it('ignores system rows that carry no slug', async () => { + const { client } = mockSupabase([ + { + id: 'legacy', pack_slug: null, name: 'Oadopterad', description: '', category: 'other', + entity_type: 'all', lines: [], is_active: true, + }, + ]) + const r = await syncSystemPacks(client as never, { dryRun: true }) + expect(r.retired).toEqual([]) + }) +}) diff --git a/lib/packs/sync.ts b/lib/packs/sync.ts new file mode 100644 index 00000000..005d540f --- /dev/null +++ b/lib/packs/sync.ts @@ -0,0 +1,188 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { loadPacks, packToLibraryRow, type LoadedPack } from './load' + +const log = createLogger('packs-sync') + +/** + * Reconcile the system booking templates in the database with `packs/*.yaml`. + * + * The packs are the source of truth; the table is the query surface. Keeping + * the rows means the existing read path (one query returning system + company + + * team templates under RLS) is untouched, and every template id stays stable. + * + * ## Why upsert on pack_slug, not name + * + * `booking_template_usage.template_id` is `ON DELETE CASCADE`. Matching on name + * would mean a corrected Swedish label looks like a new template, and the old + * row's deletion would silently wipe every company's "recently used" history + * for it. `pack_slug` is the stable identity (migration 20260803230000). + * + * ## Why retire instead of delete + * + * Same cascade. A pack removed from the catalogue deactivates its row rather + * than dropping it: the read path already filters `is_active`, so it disappears + * from the picker, but usage history and any audit trail survive. Deletion is + * never automatic here. + * + * ## Fail closed on a broken catalogue + * + * If any pack fails to parse or validate, nothing is written at all. A partial + * sync driven by a half-readable catalogue is worse than a stale one: the + * database would end up in a state no commit of the repo describes. + */ + +export interface PackSyncResult { + /** Slugs inserted as new system templates. */ + inserted: string[] + /** Slugs whose row content changed. */ + updated: string[] + /** Slugs already in sync. */ + unchanged: string[] + /** Slugs deactivated because their pack is gone. */ + retired: string[] + /** Load/validation errors. Non-empty means nothing was written. */ + errors: string[] + /** True when no write was attempted. */ + dryRun: boolean +} + +interface ExistingRow { + id: string + pack_slug: string | null + name: string + description: string + category: string + entity_type: string + lines: unknown + is_active: boolean +} + +/** Value-compare a pack against the row it maps to. */ +function rowMatchesPack(row: ExistingRow, pack: LoadedPack['pack']): boolean { + const desired = packToLibraryRow(pack) + return ( + row.is_active === true && + row.name === desired.name && + row.description === desired.description && + row.category === desired.category && + row.entity_type === desired.entity_type && + // jsonb round-trips as parsed JSON; compare by value, not key order. + JSON.stringify(normaliseLines(row.lines)) === JSON.stringify(normaliseLines(desired.lines)) + ) +} + +function normaliseLines(lines: unknown): Array> { + if (!Array.isArray(lines)) return [] + return lines.map((l) => + Object.fromEntries( + Object.entries(l as Record).sort(([a], [b]) => a.localeCompare(b)), + ), + ) +} + +export async function syncSystemPacks( + supabase: SupabaseClient, + opts: { dryRun?: boolean; root?: string } = {}, +): Promise { + const dryRun = opts.dryRun ?? false + const result: PackSyncResult = { + inserted: [], updated: [], unchanged: [], retired: [], errors: [], dryRun, + } + + const { packs, errors } = loadPacks(opts.root) + if (errors.length) { + result.errors = errors.map((e) => `${e.file}: ${e.message}`) + log.error('pack catalogue invalid, refusing to sync', { errorCount: errors.length }) + return result + } + if (packs.length === 0) { + // An empty catalogue would otherwise retire every system template. That is + // far more likely to be a broken deploy (packs/ not bundled) than intent. + result.errors.push('no packs found: refusing to retire every system template') + return result + } + + const { data, error } = await supabase + .from('booking_template_library') + .select('id, pack_slug, name, description, category, entity_type, lines, is_active') + .eq('is_system', true) + + if (error) throw error + + const existing = new Map() + for (const row of (data ?? []) as ExistingRow[]) { + if (row.pack_slug) existing.set(row.pack_slug, row) + } + + for (const p of packs) { + const slug = p.pack.meta.slug + const row = existing.get(slug) + const desired = packToLibraryRow(p.pack) + + if (!row) { + result.inserted.push(slug) + if (!dryRun) { + // Columns spelled out rather than spread: the phantom-column guard + // (tests/schema/no-phantom-columns.test.ts) cannot verify a runtime-built + // payload, and a typo'd column here would fail only in production. + const { error: insErr } = await supabase.from('booking_template_library').insert({ + name: desired.name, + description: desired.description, + category: desired.category, + entity_type: desired.entity_type, + is_system: true, + lines: desired.lines, + pack_slug: slug, + is_active: true, + }) + if (insErr) throw insErr + } + continue + } + + if (rowMatchesPack(row, p.pack)) { + result.unchanged.push(slug) + continue + } + + result.updated.push(slug) + if (!dryRun) { + const { error: updErr } = await supabase + .from('booking_template_library') + .update({ + name: desired.name, + description: desired.description, + category: desired.category, + entity_type: desired.entity_type, + lines: desired.lines, + is_active: true, + }) + .eq('id', row.id) + if (updErr) throw updErr + } + } + + const packSlugs = new Set(packs.map((p) => p.pack.meta.slug)) + for (const [slug, row] of existing) { + if (packSlugs.has(slug) || !row.is_active) continue + result.retired.push(slug) + if (!dryRun) { + const { error: retErr } = await supabase + .from('booking_template_library') + .update({ is_active: false }) + .eq('id', row.id) + if (retErr) throw retErr + } + } + + log.info('pack sync complete', { + dryRun, + inserted: result.inserted.length, + updated: result.updated.length, + unchanged: result.unchanged.length, + retired: result.retired.length, + }) + + return result +} diff --git a/supabase/migrations/20260803230000_booking_template_pack_slug.sql b/supabase/migrations/20260803230000_booking_template_pack_slug.sql new file mode 100644 index 00000000..8ade41cc --- /dev/null +++ b/supabase/migrations/20260803230000_booking_template_pack_slug.sql @@ -0,0 +1,89 @@ +-- ============================================================================= +-- booking_template_library.pack_slug +-- ============================================================================= +-- +-- System booking templates move from rows frozen inside migration +-- 20260413160000 to data files under packs/, synced by lib/packs/sync.ts. +-- +-- The sync needs a stable key to upsert against. Matching on `name` would lose +-- a template's identity the moment its Swedish label is corrected, orphaning +-- its booking_template_usage rows (which reference template_id) and resetting +-- every company's "recently used" ordering. pack_slug is that key: it is the +-- pack filename, treated as an identifier and never renamed. +-- +-- Scope: system templates only. Company- and team-authored templates keep +-- pack_slug NULL, which is why the unique index is partial. + +ALTER TABLE public.booking_template_library + ADD COLUMN IF NOT EXISTS pack_slug TEXT; + +COMMENT ON COLUMN public.booking_template_library.pack_slug IS + 'Slug of the packs/.yaml file this system template is synced from. ' + 'NULL for company- and team-authored templates. The stable upsert key for ' + 'lib/packs/sync.ts: never rename one, it is the public lookup key.'; + +-- Lowercase kebab-case, mirroring PACK_SLUG_RE in lib/packs/schema.ts so the +-- database refuses a value the loader would reject. +ALTER TABLE public.booking_template_library + DROP CONSTRAINT IF EXISTS btl_pack_slug_format; +ALTER TABLE public.booking_template_library + ADD CONSTRAINT btl_pack_slug_format + CHECK (pack_slug IS NULL OR pack_slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'); + +-- A pack maps to exactly one system template. Partial so the thousands of +-- company templates (all NULL) are not forced unique against each other. +CREATE UNIQUE INDEX IF NOT EXISTS btl_pack_slug_unique + ON public.booking_template_library (pack_slug) + WHERE pack_slug IS NOT NULL; + +-- Only a system template may carry a pack_slug: a company template claiming one +-- would collide with the pack it shadows on the next sync. +ALTER TABLE public.booking_template_library + DROP CONSTRAINT IF EXISTS btl_pack_slug_system_only; +ALTER TABLE public.booking_template_library + ADD CONSTRAINT btl_pack_slug_system_only + CHECK (pack_slug IS NULL OR is_system); + +-- ----------------------------------------------------------------------------- +-- Backfill: adopt the 26 rows seeded by 20260413160000 rather than replacing +-- them, so existing booking_template_usage rows keep pointing at a live +-- template and nobody's "recently used" list resets. +-- +-- Matched on the exact seeded name. A name that does not match any pack (none +-- today) simply stays NULL and is reported by the sync as an orphan instead of +-- being silently deleted here. +-- ----------------------------------------------------------------------------- +UPDATE public.booking_template_library SET pack_slug = v.slug +FROM (VALUES + ('Aktieägarlån — återbetalning' , 'aktieagarlan-aterbetalning'), + ('Aktieägarlån — insättning' , 'aktieagarlan-insattning'), + ('Arbetsgivaravgifter via skattekonto' , 'arbetsgivaravgifter-via-skattekonto'), + ('Arbetsgivaravgifter' , 'arbetsgivaravgifter'), + ('Bankavgift' , 'bankavgift'), + ('Beräknad bolagsskatt' , 'beraknad-bolagsskatt'), + ('Eget insättning' , 'eget-insattning'), + ('Eget uttag' , 'eget-uttag'), + ('Försäljning EU-tjänster (B2B)' , 'forsaljning-eu-tjanster-b2b'), + ('Försäljning export (utanför EU)' , 'forsaljning-export-utanfor-eu'), + ('Inköp EU-tjänster, omvänd moms 25%' , 'inkop-eu-tjanster-omvand-moms-25'), + ('Inköp EU-varor, omvänd moms 25%' , 'inkop-eu-varor-omvand-moms-25'), + ('Insättning skattekonto' , 'insattning-skattekonto'), + ('Löneutbetalning' , 'loneutbetalning'), + ('Momsbetalning via skattekonto' , 'momsbetalning-via-skattekonto'), + ('Momsredovisning (nettning)' , 'momsredovisning-nettning'), + ('Överavskrivning inventarier' , 'overavskrivning-inventarier'), + ('Periodiseringsfond återföring (AB)' , 'periodiseringsfond-aterforing-ab'), + ('Periodiseringsfond avsättning (AB)' , 'periodiseringsfond-avsattning-ab'), + ('Preliminär F-skatt (AB)' , 'preliminar-f-skatt-ab'), + ('Preliminär F-skatt (EF)' , 'preliminar-f-skatt-ef'), + ('Ränteintäkt' , 'ranteintakt'), + ('Räntekostnad' , 'rantekostnad'), + ('Representation (avdragsgill, 25% moms)', 'representation-avdragsgill-25-moms'), + ('Skatteåterbäring' , 'skatteaterbaring'), + ('Utdelning till aktieägare' , 'utdelning-till-aktieagare') +) AS v(name, slug) +WHERE public.booking_template_library.is_system + AND public.booking_template_library.name = v.name + AND public.booking_template_library.pack_slug IS NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/booking-template-pack-slug.pg.test.ts b/tests/pg/booking-template-pack-slug.pg.test.ts new file mode 100644 index 00000000..5070755f --- /dev/null +++ b/tests/pg/booking-template-pack-slug.pg.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { seedCompany } from './fixtures' + +/** + * `booking_template_library.pack_slug` (migration 20260803230000). + * + * The column is the stable upsert key that lets system templates be synced from + * packs/*.yaml instead of living as rows frozen inside migration 20260413160000. + * Its three guards each stop a specific way the sync could corrupt the + * catalogue, so each is asserted here rather than trusted. + */ +describe('booking_template_library.pack_slug', () => { + it('backfilled every seeded system template', async () => { + const { rows } = await getPool().query( + `SELECT count(*) FILTER (WHERE pack_slug IS NULL) AS missing, count(*) AS total + FROM public.booking_template_library WHERE is_system`, + ) + // A system template without a slug would be invisible to the sync and would + // silently persist as a duplicate of whatever pack replaced it. + expect(Number(rows[0].missing)).toBe(0) + expect(Number(rows[0].total)).toBeGreaterThan(0) + }) + + it('holds a unique slug per system template', async () => { + const { rows } = await getPool().query( + `SELECT pack_slug, count(*) AS n FROM public.booking_template_library + WHERE pack_slug IS NOT NULL GROUP BY pack_slug HAVING count(*) > 1`, + ) + expect(rows).toEqual([]) + }) + + it('rejects a second row claiming an existing slug', async () => { + const existing = await getPool().query( + `SELECT pack_slug FROM public.booking_template_library WHERE pack_slug IS NOT NULL LIMIT 1`, + ) + const slug = existing.rows[0].pack_slug as string + + await expect( + getPool().query( + `INSERT INTO public.booking_template_library + (name, description, category, entity_type, is_system, lines, pack_slug) + VALUES ('Dubblett', '', 'other', 'all', TRUE, '[]'::jsonb, $1)`, + [slug], + ), + ).rejects.toThrow(/btl_pack_slug_unique/) + }) + + it('rejects a slug that the pack loader would refuse', async () => { + for (const bad of ['Bad_Slug', 'trailing-', '-leading', 'double--dash', 'ÅÄÖ']) { + await expect( + getPool().query( + `INSERT INTO public.booking_template_library + (name, description, category, entity_type, is_system, lines, pack_slug) + VALUES ('Ogiltig', '', 'other', 'all', TRUE, '[]'::jsonb, $1)`, + [bad], + ), + bad, + ).rejects.toThrow(/btl_pack_slug_format/) + } + }) + + it('refuses a pack_slug on a COMPANY template, which would shadow the pack', async () => { + // Scoped to a real company so the pre-existing "company or team or system" + // CHECK is satisfied and pack_slug is genuinely the constraint under test. + const { companyId, userId } = await seedCompany() + + await expect( + getPool().query( + `INSERT INTO public.booking_template_library + (id, company_id, created_by, name, description, category, entity_type, + is_system, lines, pack_slug) + VALUES ($1, $2, $3, 'Kapad mall', '', 'other', 'all', FALSE, '[]'::jsonb, 'kapad-mall')`, + [randomUUID(), companyId, userId], + ), + ).rejects.toThrow(/btl_pack_slug_system_only/) + }) + + it('still allows a company template with no slug', async () => { + const { companyId, userId } = await seedCompany() + const id = randomUUID() + + await getPool().query( + `INSERT INTO public.booking_template_library + (id, company_id, created_by, name, description, category, entity_type, is_system, lines) + VALUES ($1, $2, $3, 'Egen mall', '', 'other', 'all', FALSE, '[]'::jsonb)`, + [id, companyId, userId], + ) + + const { rows } = await getPool().query( + `SELECT pack_slug FROM public.booking_template_library WHERE id = $1`, + [id], + ) + expect(rows[0].pack_slug).toBeNull() + }) +}) diff --git a/vercel.json b/vercel.json index 418018fb..8b28973b 100644 --- a/vercel.json +++ b/vercel.json @@ -9,6 +9,10 @@ "path": "/api/invoices/recurring/cron", "schedule": "0 * * * *" }, + { + "path": "/api/settings/booking-templates/sync/cron", + "schedule": "30 4 * * *" + }, { "path": "/api/tax-deadlines/cron", "schedule": "0 0 * * *"