mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
ad60167a31
commit
33b6e2a2a6
20 files changed
+598
-41
No files matched your search
@@ -0,0 +1,124 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var intakeKeyRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// ParseIntakeFields decodes the stored JSON list of declared intake fields;
|
||||
// empty or corrupt values decode to nil so a bad row cannot break listing.
|
||||
func ParseIntakeFields(raw string) []domain.IntakeField {
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var fields []domain.IntakeField
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// NormalizeIntakeFields validates a service's declared intake fields and
|
||||
// returns the canonical JSON string for storage. Keys are slugified from the
|
||||
// provided key or the label, must be unique, and fields are capped at 8.
|
||||
// Returns the wrapped error unchanged when it is already an app error.
|
||||
func NormalizeIntakeFields(fields []domain.IntakeField) (string, error) {
|
||||
if len(fields) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
if len(fields) > 8 {
|
||||
return "", fmt.Errorf("intakeFields supports at most 8 fields")
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
out := make([]domain.IntakeField, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
label := strings.TrimSpace(f.Label)
|
||||
if len(label) < 1 || len(label) > 80 {
|
||||
return "", fmt.Errorf("intake field label must be 1-80 characters")
|
||||
}
|
||||
ftype := strings.ToLower(strings.TrimSpace(f.Type))
|
||||
if ftype != "text" && ftype != "yesno" {
|
||||
return "", fmt.Errorf("intake field type must be text or yesno")
|
||||
}
|
||||
key := strings.TrimSpace(f.Key)
|
||||
if key == "" {
|
||||
key = label
|
||||
}
|
||||
key = strings.Trim(intakeKeyRe.ReplaceAllString(strings.ToLower(key), "_"), "_")
|
||||
if key == "" || len(key) > 40 {
|
||||
return "", fmt.Errorf("intake field key must be 1-40 slug characters")
|
||||
}
|
||||
if seen[key] {
|
||||
return "", fmt.Errorf("duplicate intake field key %q", key)
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, domain.IntakeField{Key: key, Label: label, Type: ftype, Required: f.Required})
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// ParseIntakeAnswers decodes a booking's stored intake JSON object; empty or
|
||||
// corrupt values decode to nil.
|
||||
func ParseIntakeAnswers(raw string) map[string]string {
|
||||
if raw == "" || raw == "{}" {
|
||||
return nil
|
||||
}
|
||||
var answers map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &answers); err != nil {
|
||||
return nil
|
||||
}
|
||||
return answers
|
||||
}
|
||||
|
||||
// NormalizeIntakeAnswers validates a customer's intake answers against the
|
||||
// service's declared fields and returns the canonical JSON object. Required
|
||||
// fields must be present and non-empty; yesno coerces to "yes"/"no"; text is
|
||||
// trimmed and capped at 500 chars. Only declared keys are stored — unknown
|
||||
// keys are dropped rather than persisted.
|
||||
func NormalizeIntakeAnswers(fields []domain.IntakeField, intake map[string]string) (string, error) {
|
||||
if len(fields) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
out := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
raw, ok := intake[f.Key]
|
||||
value := strings.TrimSpace(raw)
|
||||
if !ok || value == "" {
|
||||
if f.Required {
|
||||
return "", fmt.Errorf("intake field %q is required", f.Label)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch f.Type {
|
||||
case "yesno":
|
||||
switch strings.ToLower(value) {
|
||||
case "yes", "ano", "true", "1":
|
||||
value = "yes"
|
||||
case "no", "ne", "false", "0":
|
||||
value = "no"
|
||||
default:
|
||||
return "", fmt.Errorf("intake field %q must be yes or no", f.Label)
|
||||
}
|
||||
default:
|
||||
if len(value) > 500 {
|
||||
return "", fmt.Errorf("intake field %q must be at most 500 characters", f.Label)
|
||||
}
|
||||
}
|
||||
out[f.Key] = value
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user