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

+24
View File
@@ -13,6 +13,7 @@ import (
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"bookra/apps/backend/internal/shared"
"github.com/jackc/pgx/v5"
)
@@ -200,6 +201,16 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
// SERVICE CATALOG MANAGEMENT
// ============================================
// normalizeIntakeFields validates declared intake fields via the shared
// normalizer and wraps failures in ErrInvalidInput for the API layer.
func normalizeIntakeFields(fields []domain.IntakeField) (string, error) {
raw, err := shared.NormalizeIntakeFields(fields)
if err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidInput, err.Error())
}
return raw, nil
}
func serviceToDomain(rec db.ServiceRecord) domain.Service {
return domain.Service{
ID: rec.ID,
@@ -209,6 +220,7 @@ func serviceToDomain(rec db.ServiceRecord) domain.Service {
BufferBeforeMinutes: rec.BufferBeforeMinutes,
BufferAfterMinutes: rec.BufferAfterMinutes,
PriceCents: rec.PriceCents,
IntakeFields: shared.ParseIntakeFields(rec.IntakeFields),
CreatedAt: rec.CreatedAt,
}
}
@@ -260,6 +272,10 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal,
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
return domain.Service{}, err
}
intakeJSON, err := normalizeIntakeFields(req.IntakeFields)
if err != nil {
return domain.Service{}, err
}
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
TenantID: membership.Tenant.ID,
@@ -268,6 +284,7 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal,
BufferBeforeMinutes: req.BufferBeforeMinutes,
BufferAfterMinutes: req.BufferAfterMinutes,
PriceCents: req.PriceCents,
IntakeFields: intakeJSON,
})
if err != nil {
return domain.Service{}, err
@@ -303,6 +320,13 @@ func (s *Service) UpdateService(ctx context.Context, principal domain.Principal,
if req.PriceCents != nil {
params.PriceCents = req.PriceCents
}
if req.IntakeFields != nil {
intakeJSON, err := normalizeIntakeFields(*req.IntakeFields)
if err != nil {
return domain.Service{}, err
}
params.IntakeFields = &intakeJSON
}
// Validate against the merged result so partial updates cannot produce an
// invalid record (e.g. setting duration to 0 while keeping the name).