feat(packs): sync system templates from packs/ instead of the frozen migration (#1390)

Phase 2b. The packs become the source of truth; the table stays the query
surface, so the existing read path (one RLS query returning system + company +
team templates) and every template id are untouched.

pack_slug is the stable upsert key (migration 20260803230000, backfilled onto
all 26 seeded rows). Matching on name instead would have meant that correcting a
Swedish label looks like a new template, and because
booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row
would silently wipe every company's "recently used" history for it. For the same
reason a removed pack DEACTIVATES its row rather than deleting it: the read path
already filters is_active, so it leaves the picker while usage history survives.

The sync fails closed. A pack that will not parse or validate aborts the whole
run with zero writes, because a database left in a state no commit of the repo
describes is worse than a stale one. An empty catalogue is treated as a broken
deploy (packs/ not bundled) rather than an instruction to retire every system
template.

Runs as a daily cron rather than at boot: boot-time work would have every
serverless instance racing to write the same rows, and would re-apply a bad
catalogue continuously instead of once a day where it is visible. Idempotent, so
a database already matching the packs performs zero writes.

Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts:
a partial unique index (one pack, one template), a format CHECK mirroring
PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping
pack_slug off company templates, where it would shadow the pack it collides with.

Verified the upgrade path locally the way the pg-upgrade job will run it: base
schema, seeded fixture company with posted verifikat, an existing company
template, then this migration alone. 26 slugs backfilled, company template
intact, upgrade assertions pass. This is that job's first real migration.

Payloads are spelled out rather than spread so the phantom-column guard can
check them, and docker/crontab.* are regenerated for the new vercel.json entry.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-03 18:55:59 +02:00
committed by GitHub
parent 0ef3c03904
commit ff864ad3db
9 changed files with 694 additions and 0 deletions
@@ -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()
})
})
@@ -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,
},
})
})
+1
View File
@@ -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
+1
View File
@@ -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
+171
View File
@@ -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<string, unknown> }> = []
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<string, unknown>) => ({
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([])
})
})
+188
View File
@@ -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<Record<string, unknown>> {
if (!Array.isArray(lines)) return []
return lines.map((l) =>
Object.fromEntries(
Object.entries(l as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
),
)
}
export async function syncSystemPacks(
supabase: SupabaseClient,
opts: { dryRun?: boolean; root?: string } = {},
): Promise<PackSyncResult> {
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<string, ExistingRow>()
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
}
@@ -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/<slug>.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';
@@ -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()
})
})
+4
View File
@@ -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 * * *"