Files
Bookra/apps/backend/internal/shared/intake.go
T
Tomas DvorakandDevin 33b6e2a2a6 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>
2026-09-18 18:04:32 +02:00

125 lines
3.6 KiB
Go

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
}