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

@@ -0,0 +1,83 @@
package shared
import (
"strings"
"testing"
"bookra/apps/backend/internal/domain"
)
func TestNormalizeIntakeFields(t *testing.T) {
raw, err := NormalizeIntakeFields([]domain.IntakeField{
{Label: "First visit?", Type: "yesno", Required: true},
{Key: "injuries", Label: "Injuries or health notes", Type: "text"},
})
if err != nil {
t.Fatalf("normalize: %v", err)
}
fields := ParseIntakeFields(raw)
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].Key != "first_visit" || !fields[0].Required {
t.Fatalf("unexpected field normalization: %+v", fields[0])
}
if _, err := NormalizeIntakeFields([]domain.IntakeField{
{Label: "A", Type: "yesno"}, {Label: "A", Type: "text"},
}); err == nil {
t.Fatal("expected duplicate key error")
}
if _, err := NormalizeIntakeFields([]domain.IntakeField{
{Label: "A", Type: "radio"},
}); err == nil {
t.Fatal("expected bad type error")
}
nine := make([]domain.IntakeField, 9)
for i := range nine {
nine[i] = domain.IntakeField{Key: strings.Repeat("k", i+1), Label: "L", Type: "text"}
}
if _, err := NormalizeIntakeFields(nine); err == nil {
t.Fatal("expected max-8 error")
}
}
func TestNormalizeIntakeAnswers(t *testing.T) {
fields := []domain.IntakeField{
{Key: "first_visit", Label: "First visit?", Type: "yesno", Required: true},
{Key: "injuries", Label: "Injuries?", Type: "text"},
}
raw, err := NormalizeIntakeAnswers(fields, map[string]string{
"first_visit": "yes",
"injuries": " sore knee ",
"undeclared": "dropped",
})
if err != nil {
t.Fatalf("normalize answers: %v", err)
}
answers := ParseIntakeAnswers(raw)
if answers["first_visit"] != "yes" || answers["injuries"] != "sore knee" {
t.Fatalf("unexpected answers: %+v", answers)
}
if _, ok := answers["undeclared"]; ok {
t.Fatal("undeclared key should be dropped")
}
if _, err := NormalizeIntakeAnswers(fields, map[string]string{}); err == nil {
t.Fatal("expected required-field error")
}
if _, err := NormalizeIntakeAnswers(fields, map[string]string{"first_visit": "maybe"}); err == nil {
t.Fatal("expected yesno coercion error")
}
if _, err := NormalizeIntakeAnswers(fields, map[string]string{
"first_visit": "no", "injuries": strings.Repeat("x", 501),
}); err == nil {
t.Fatal("expected text cap error")
}
raw, err = NormalizeIntakeAnswers(nil, map[string]string{"anything": "ignored"})
if err != nil || raw != "{}" {
t.Fatalf("expected {} for no declared fields, got %q err=%v", raw, err)
}
}