Files
accounted/supabase/migrations/20260803230000_booking_template_pack_slug.sql
T
ff864ad3db 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>
2026-08-03 18:55:59 +02:00

90 lines
4.9 KiB
SQL

-- =============================================================================
-- 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';