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 } // IntakeLabels maps a service's declared intake field keys to their display // labels, so booking views can render questions rather than slugs. func IntakeLabels(raw string) map[string]string { fields := ParseIntakeFields(raw) if len(fields) == 0 { return nil } labels := make(map[string]string, len(fields)) for _, field := range fields { labels[field.Key] = field.Label } return labels } // 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 }