mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
- booking views gain intakeLabels (declared key→label join); dashboard modal + manage view render real question labels with slug fallback - GET /v1/metrics/services aggregates bookings/completed/cancelled/ revenue per service + classBookings; service-report.csv delegates to the same ServiceMetrics aggregation and gains a completed column - services page gains a Service performance card (per-service stat grid, locale CZK revenue, class-bookings footer); demo rows match the fabricated service list - staff capacity utilization subtracts blocked days — tenant-wide and staff-scoped blocks remove that day's rule minutes via blockedAt - tests: blocked-day capacity (tenant + staff scope), service aggregation Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
139 lines
4.0 KiB
Go
139 lines
4.0 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
|
|
}
|
|
|
|
// 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
|
|
}
|