import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { UUID_RE } from '@/lib/invariants/uuid' import { BookingTemplateCategorySchema, BookingTemplateEntityTypeSchema, BookingTemplateLineSchema, } from '@/lib/bookkeeping/booking-template-schemas' // The GET scope below builds a PostgREST .or() filter by string interpolation. // Guard every interpolated id against a strict UUID shape (UUID_RE) so a // tainted value can never inject filter syntax. Both ids are server-derived // (companyId from membership, teamId from a DB column), so this is // defense-in-depth. const CreateBookingTemplateSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(2000).default(''), category: BookingTemplateCategorySchema.default('other'), entity_type: BookingTemplateEntityTypeSchema.default('all'), lines: z.array(BookingTemplateLineSchema).min(2), team_id: z.string().uuid().optional(), }) /** * GET /api/settings/booking-templates * Returns all templates visible to the current user: * system + company + team templates. * * Ordering: most recently used (per current company) first, then by category * and name for never-used templates. Usage is tracked in * booking_template_usage via POST /[id]/touch. */ export const GET = withRouteContext( 'booking_template.list', async (_request, ctx) => { const { supabase, companyId } = ctx // Resolve the team this company belongs to (if any) so team-shared // templates stay visible while this company is selected. const { data: company } = await supabase .from('companies') .select('team_id') .eq('id', companyId) .maybeSingle() const teamId = company?.team_id ?? null // The wrapper only ever resolves a real membership UUID, but assert the // shape before interpolating it into the .or() filter. if (!UUID_RE.test(companyId)) { return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) } // Scope to the SELECTED company: system + this company + this company's team. // RLS (btl_select) is membership-wide: it returns templates from *every* // company the user belongs to: so the active-company narrowing must happen // here in the API layer (mirrors counterparty-templates). Without this, a // user who owns several companies sees all of their templates merged. // Only interpolate a team id that passes the strict UUID guard. const scope = [ 'is_system.eq.true', `company_id.eq.${companyId}`, ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), ].join(',') const [templatesRes, usageRes, hiddenRes] = await Promise.all([ supabase .from('booking_template_library') .select('*') .eq('is_active', true) .or(scope) .order('category') .order('name'), supabase .from('booking_template_usage') .select('template_id, last_used_at') .eq('company_id', companyId), supabase .from('booking_template_hidden') .select('template_id') .eq('company_id', companyId), ]) if (templatesRes.error) { return NextResponse.json({ error: getUserErrorMessage(templatesRes.error) }, { status: 500 }) } // usage lookup failing is non-fatal: we just fall back to default ordering const usageByTemplate = new Map() if (!usageRes.error && usageRes.data) { for (const row of usageRes.data) { usageByTemplate.set(row.template_id, row.last_used_at) } } // hidden lookup failing is also non-fatal: falling back to "nothing // hidden" shows extra templates, which is the safe direction. const hiddenIds = new Set() if (!hiddenRes.error && hiddenRes.data) { for (const row of hiddenRes.data) { hiddenIds.add(row.template_id) } } const templates = templatesRes.data ?? [] const decorated = templates.map((t) => ({ ...t, last_used_at: usageByTemplate.get(t.id) ?? null, is_hidden: hiddenIds.has(t.id), })) // Stable-sort: templates with last_used_at come first (most-recent first). // Templates without usage keep their category/name order from the query. // ISO 8601 timestamps are fixed-width ASCII: plain relational comparison // is correct and avoids any locale-dependent behaviour from localeCompare. decorated.sort((a, b) => { const aUsed = a.last_used_at const bUsed = b.last_used_at if (aUsed && bUsed) { if (bUsed > aUsed) return -1 if (bUsed < aUsed) return 1 return 0 } if (aUsed) return -1 if (bUsed) return 1 return 0 }) return NextResponse.json({ data: decorated }) }, ) /** * POST /api/settings/booking-templates * Create a company-scoped or team-scoped template. */ export const POST = withRouteContext( 'booking_template.create', async (request, ctx) => { const { supabase, user } = ctx const result = await validateBody(request, CreateBookingTemplateSchema) if (!result.success) return result.response const body = result.data const companyId = body.team_id ? null : ctx.companyId const { data, error } = await supabase .from('booking_template_library') .insert({ company_id: companyId, team_id: body.team_id ?? null, created_by: user.id, name: body.name, description: body.description, category: body.category, entity_type: body.entity_type, lines: body.lines, is_system: false, }) .select() .single() if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) return NextResponse.json({ data }, { status: 201 }) }, { requireWrite: true }, ) /** * DELETE /api/settings/booking-templates * Soft-delete a template by id (company or team scope only, never system). */ export const DELETE = withRouteContext( 'booking_template.delete', async (request, ctx) => { const { supabase } = ctx let id: string | undefined try { const body = await request.json() id = body?.id } catch { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) const { error } = await supabase .from('booking_template_library') .update({ is_active: false }) .eq('id', id) if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) return NextResponse.json({ data: { success: true } }) }, { requireWrite: true }, )