* 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>
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
/**
|
|
* POST /api/settings/booking-templates/[id]/hide
|
|
*
|
|
* Hide a system template for the current company. Opt-in and per-company:
|
|
* nothing is hidden by default, and a hide row never affects any other
|
|
* company. Company/team templates are excluded on purpose: they already have
|
|
* a real delete path, and hiding them would just be a confusing second one.
|
|
*
|
|
* DELETE /api/settings/booking-templates/[id]/hide
|
|
*
|
|
* Unhide (restore) the template for the current company.
|
|
*/
|
|
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'booking_template.hide',
|
|
async (_request, ctx, { params }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, user } = ctx
|
|
|
|
// Only an existing, active SYSTEM template can be hidden. RLS on
|
|
// booking_template_hidden scopes the write to the active company; this
|
|
// check scopes it to the right kind of template.
|
|
const { data: template, error: templateError } = await supabase
|
|
.from('booking_template_library')
|
|
.select('id, is_system, is_active')
|
|
.eq('id', id)
|
|
.maybeSingle()
|
|
|
|
if (templateError) {
|
|
return NextResponse.json({ error: getUserErrorMessage(templateError) }, { status: 500 })
|
|
}
|
|
if (!template || !template.is_active) {
|
|
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
|
}
|
|
if (!template.is_system) {
|
|
return NextResponse.json({ error: 'Only system templates can be hidden' }, { status: 400 })
|
|
}
|
|
|
|
// ignoreDuplicates makes the conflict arm DO NOTHING. The table has no
|
|
// UPDATE policy (insert-or-delete only), so a DO UPDATE arm would be
|
|
// rejected by RLS and turn a concurrent re-hide into a 500.
|
|
const { error } = await supabase
|
|
.from('booking_template_hidden')
|
|
.upsert(
|
|
{ template_id: id, company_id: companyId, hidden_by: user.id },
|
|
{ onConflict: 'template_id,company_id', ignoreDuplicates: true },
|
|
)
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data: { success: true } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'booking_template.unhide',
|
|
async (_request, ctx, { params }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId } = ctx
|
|
|
|
// Deleting a row that does not exist is a no-op success: unhide is
|
|
// idempotent, and the panel may race a double click.
|
|
const { error } = await supabase
|
|
.from('booking_template_hidden')
|
|
.delete()
|
|
.eq('template_id', id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data: { success: true } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|