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

@@ -221,6 +221,7 @@ func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.Public
Name: service.Name,
DurationMinutes: service.DurationMinutes,
PriceCents: service.PriceCents,
IntakeFields: shared.ParseIntakeFields(service.IntakeFields),
})
}
for _, staff := range staffRecords {
@@ -283,6 +284,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
status := "confirmed"
activityLabel := ""
intakeJSON := "{}"
switch request.BookingMode {
case "appointment":
if request.ServiceID == nil || request.ClassSessionID != nil {
@@ -295,6 +297,10 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if !ok {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
}
intakeJSON, err = shared.NormalizeIntakeAnswers(shared.ParseIntakeFields(service.IntakeFields), request.Intake)
if err != nil {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: %s", ErrInvalidBooking, err.Error())
}
activityLabel = service.Name
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
if !startsAt.Add(expectedDuration).Equal(endsAt) {
@@ -350,6 +356,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
Reference: db.Reference("BK", time.Now()),
ManageToken: generateManageToken(),
Notes: notes,
Intake: intakeJSON,
})
if err != nil {
if db.IsExclusionViolation(err) {
@@ -565,6 +572,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Notes: booking.Notes,
Intake: shared.ParseIntakeAnswers(booking.Intake),
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,