feat(intake): structured per-service intake questions

Services declare up to 8 intake fields (text/yesno, required) stored
as JSONB; shared/intake.go is the single normalization point — slug
keys, label/dup/unknown-key rules, yes/no canonicalization. Booking
create validates answers against the service declaration (missing
required -> 400), unknown keys are dropped before persist.

Answers surface on UpcomingBooking and CustomerBookingView; the
services page gains a compact editor and the public form renders
declared questions under contact details. Verified live end-to-end.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 18:04:32 +02:00
1 parent ad60167a31
commit 33b6e2a2a6
20 files changed
+598 -41

No files matched your search

@@ -1413,6 +1413,10 @@ export interface components {
customerName: string;
/** Format: date-time */
endsAt: string;
/** @description Structured intake answers keyed by the service's declared intakeFields keys */
intake?: {
[key: string]: string;
};
/** Format: uuid */
locationId?: string;
notes?: string;
@@ -1475,6 +1479,9 @@ export interface components {
customerName?: string;
/** Format: date-time */
endsAt?: string;
intake?: {
[key: string]: string;
};
location?: string;
notes?: string;
reference?: string;
@@ -1547,6 +1554,15 @@ export interface components {
/** @example ok */
status: string;
};
IntakeField: {
/** @description Slug key (lowercase alnum + underscore); JSON key in booking intake answers */
key: string;
/** @description Customer-facing question text */
label: string;
required?: boolean;
/** @enum {string} */
type: "text" | "yesno";
};
Location: {
address?: string;
capacity?: number;
@@ -1626,6 +1642,7 @@ export interface components {
durationMinutes: number;
/** Format: uuid */
id: string;
intakeFields?: components["schemas"]["IntakeField"][];
name: string;
priceCents: number;
};
@@ -1645,6 +1662,8 @@ export interface components {
durationMinutes: number;
/** Format: uuid */
id: string;
/** @description Structured intake questions asked at booking time (max 8) */
intakeFields?: components["schemas"]["IntakeField"][];
name: string;
/** @description Price in the tenant's currency minor units */
priceCents: number;
@@ -1822,6 +1841,10 @@ export interface components {
endsAt?: string;
/** Format: uuid */
id?: string;
/** @description Structured intake answers keyed by the service's declared intakeFields keys */
intake?: {
[key: string]: string;
};
label?: string;
location?: string;
notes?: string;
@@ -1883,6 +1906,7 @@ export interface components {
bufferAfterMinutes?: number;
bufferBeforeMinutes?: number;
durationMinutes: number;
intakeFields?: components["schemas"]["IntakeField"][];
name: string;
priceCents?: number;
};