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>
This commit is contained in:
Mattsson
2026-08-28 12:12:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5720632832
commit 57d4359d1a
15 changed files with 834 additions and 18 deletions
+21 -8
View File
@@ -153,21 +153,34 @@ export default function BulkBookDialog({
)
// Load templates when the dialog opens. RLS scopes to user's companies +
// system templates; no company_id filter needed.
// system templates; no company_id filter needed. Templates the company hid
// (booking_template_hidden, per-company opt-in) are excluded like in the
// other pickers; if that lookup fails we show everything (safe direction).
useEffect(() => {
if (!open || !company) return
const companyId = company.id
let cancelled = false
async function load() {
setLoadingTemplates(true)
try {
const { data } = await supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.order('is_system', { ascending: false })
.order('name', { ascending: true })
const [templatesRes, hiddenRes] = await Promise.all([
supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.order('is_system', { ascending: false })
.order('name', { ascending: true }),
supabase
.from('booking_template_hidden')
.select('template_id')
.eq('company_id', companyId),
])
if (cancelled) return
setTemplates((data ?? []) as BookingTemplateLibrary[])
const hiddenIds = new Set(
(hiddenRes.error ? [] : hiddenRes.data ?? []).map((r) => r.template_id as string),
)
const rows = (templatesRes.data ?? []) as BookingTemplateLibrary[]
setTemplates(rows.filter((tpl) => !hiddenIds.has(tpl.id)))
} finally {
if (!cancelled) setLoadingTemplates(false)
}