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

+19 -4
View File
@@ -276,6 +276,9 @@ type ServiceRecord struct {
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
// IntakeFields is the raw JSON list of declared structured intake fields;
// the catalog service parses and validates it into domain.IntakeField.
IntakeFields string
CreatedAt time.Time
}
@@ -286,6 +289,7 @@ type CreateServiceParams struct {
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
IntakeFields string
}
// Pointer fields distinguish "not sent" (keep current value) from an explicit
@@ -296,6 +300,7 @@ type UpdateServiceParams struct {
BufferBeforeMinutes *int
BufferAfterMinutes *int
PriceCents *int
IntakeFields *string
}
type StaffRecord struct {
@@ -447,6 +452,8 @@ type BookingRecord struct {
Reference string
ManageToken string
Notes string
// Intake is the raw JSON object of structured intake answers.
Intake string
CreatedAt time.Time
}
@@ -466,6 +473,7 @@ type CreateBookingParams struct {
Reference string
ManageToken string
Notes string
Intake string
}
type CreatedBooking struct {
@@ -821,11 +829,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt)
return rec, err
}
@@ -834,11 +842,11 @@ func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (Bo
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
FROM bookings
WHERE id = $1
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt)
return rec, err
}
@@ -1215,6 +1223,7 @@ func (r *MemoryRepository) CreateService(_ context.Context, params CreateService
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
IntakeFields: params.IntakeFields,
CreatedAt: time.Now().UTC(),
}
r.services = append(r.services, service)
@@ -1241,6 +1250,9 @@ func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, pa
if params.PriceCents != nil {
service.PriceCents = *params.PriceCents
}
if params.IntakeFields != nil {
service.IntakeFields = *params.IntakeFields
}
r.services[i] = service
return service, nil
}
@@ -1749,6 +1761,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
Intake: params.Intake,
CreatedAt: time.Now().UTC(),
})
return created, nil
@@ -1764,6 +1777,7 @@ func (r *MemoryRepository) seedDemoBookings() {
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now},
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now},
)
r.services[0].IntakeFields = `[{"key":"health_notes","label":"Health notes or allergies","type":"text","required":false},{"key":"first_visit","label":"Is this your first visit?","type":"yesno","required":true}]`
seed := func(name, email, status string, startsAt time.Time) {
r.bookings = append(r.bookings, BookingRecord{
ID: uuid.NewString(),
@@ -1785,6 +1799,7 @@ func (r *MemoryRepository) seedDemoBookings() {
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
seed("Alice Johnson", "[email protected]", "cancelled", now.Add(-74*time.Hour))
r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}`
}
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {