mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +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
@@ -379,6 +379,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||
CustomerPhone string `json:"customerPhone"`
|
||||
Notes string `json:"notes"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt string `json:"startsAt" binding:"required"`
|
||||
EndsAt string `json:"endsAt" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -134,6 +135,7 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
||||
Location: locationName,
|
||||
Status: booking.Status,
|
||||
Notes: booking.Notes,
|
||||
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||
WaitlistPosition: position,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.Public
|
||||
Name: service.Name,
|
||||
DurationMinutes: service.DurationMinutes,
|
||||
PriceCents: service.PriceCents,
|
||||
IntakeFields: shared.ParseIntakeFields(service.IntakeFields),
|
||||
})
|
||||
}
|
||||
for _, staff := range staffRecords {
|
||||
@@ -283,6 +284,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
|
||||
status := "confirmed"
|
||||
activityLabel := ""
|
||||
intakeJSON := "{}"
|
||||
switch request.BookingMode {
|
||||
case "appointment":
|
||||
if request.ServiceID == nil || request.ClassSessionID != nil {
|
||||
@@ -295,6 +297,10 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
if !ok {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
||||
}
|
||||
intakeJSON, err = shared.NormalizeIntakeAnswers(shared.ParseIntakeFields(service.IntakeFields), request.Intake)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: %s", ErrInvalidBooking, err.Error())
|
||||
}
|
||||
activityLabel = service.Name
|
||||
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
||||
@@ -350,6 +356,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
Reference: db.Reference("BK", time.Now()),
|
||||
ManageToken: generateManageToken(),
|
||||
Notes: notes,
|
||||
Intake: intakeJSON,
|
||||
})
|
||||
if err != nil {
|
||||
if db.IsExclusionViolation(err) {
|
||||
@@ -565,6 +572,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Notes: booking.Notes,
|
||||
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Status: booking.Status,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/shared"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -200,6 +201,16 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
|
||||
// SERVICE CATALOG MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
// normalizeIntakeFields validates declared intake fields via the shared
|
||||
// normalizer and wraps failures in ErrInvalidInput for the API layer.
|
||||
func normalizeIntakeFields(fields []domain.IntakeField) (string, error) {
|
||||
raw, err := shared.NormalizeIntakeFields(fields)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidInput, err.Error())
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func serviceToDomain(rec db.ServiceRecord) domain.Service {
|
||||
return domain.Service{
|
||||
ID: rec.ID,
|
||||
@@ -209,6 +220,7 @@ func serviceToDomain(rec db.ServiceRecord) domain.Service {
|
||||
BufferBeforeMinutes: rec.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: rec.BufferAfterMinutes,
|
||||
PriceCents: rec.PriceCents,
|
||||
IntakeFields: shared.ParseIntakeFields(rec.IntakeFields),
|
||||
CreatedAt: rec.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -260,6 +272,10 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal,
|
||||
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
intakeJSON, err := normalizeIntakeFields(req.IntakeFields)
|
||||
if err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
|
||||
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
|
||||
TenantID: membership.Tenant.ID,
|
||||
@@ -268,6 +284,7 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal,
|
||||
BufferBeforeMinutes: req.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: req.BufferAfterMinutes,
|
||||
PriceCents: req.PriceCents,
|
||||
IntakeFields: intakeJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Service{}, err
|
||||
@@ -303,6 +320,13 @@ func (s *Service) UpdateService(ctx context.Context, principal domain.Principal,
|
||||
if req.PriceCents != nil {
|
||||
params.PriceCents = req.PriceCents
|
||||
}
|
||||
if req.IntakeFields != nil {
|
||||
intakeJSON, err := normalizeIntakeFields(*req.IntakeFields)
|
||||
if err != nil {
|
||||
return domain.Service{}, err
|
||||
}
|
||||
params.IntakeFields = &intakeJSON
|
||||
}
|
||||
|
||||
// Validate against the merged result so partial updates cannot produce an
|
||||
// invalid record (e.g. setting duration to 0 while keeping the name).
|
||||
|
||||
@@ -12,7 +12,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
|
||||
FROM bookings
|
||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||
ORDER BY starts_at ASC
|
||||
@@ -41,6 +41,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
||||
&record.Reference,
|
||||
&record.ManageToken,
|
||||
&record.Notes,
|
||||
&record.Intake,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -56,7 +57,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
|
||||
FROM bookings
|
||||
WHERE class_session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
@@ -85,6 +86,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri
|
||||
&record.Reference,
|
||||
&record.ManageToken,
|
||||
&record.Notes,
|
||||
&record.Intake,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -100,9 +102,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
||||
INSERT INTO bookings (
|
||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
|
||||
status, reference, manage_token, notes
|
||||
status, reference, manage_token, notes, intake
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)
|
||||
RETURNING id, reference, status, manage_token
|
||||
`,
|
||||
params.TenantID,
|
||||
@@ -120,6 +122,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
||||
params.Reference,
|
||||
params.ManageToken,
|
||||
params.Notes,
|
||||
params.Intake,
|
||||
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
|
||||
return created, err
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ type ServiceRecord struct {
|
||||
BufferBeforeMinutes int
|
||||
BufferAfterMinutes int
|
||||
PriceCents int
|
||||
// IntakeFields is the raw JSON list of declared structured intake fields;
|
||||
// the catalog service parses and validates it into domain.IntakeField.
|
||||
IntakeFields string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -286,6 +289,7 @@ type CreateServiceParams struct {
|
||||
BufferBeforeMinutes int
|
||||
BufferAfterMinutes int
|
||||
PriceCents int
|
||||
IntakeFields string
|
||||
}
|
||||
|
||||
// Pointer fields distinguish "not sent" (keep current value) from an explicit
|
||||
@@ -296,6 +300,7 @@ type UpdateServiceParams struct {
|
||||
BufferBeforeMinutes *int
|
||||
BufferAfterMinutes *int
|
||||
PriceCents *int
|
||||
IntakeFields *string
|
||||
}
|
||||
|
||||
type StaffRecord struct {
|
||||
@@ -447,6 +452,8 @@ type BookingRecord struct {
|
||||
Reference string
|
||||
ManageToken string
|
||||
Notes string
|
||||
// Intake is the raw JSON object of structured intake answers.
|
||||
Intake string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -466,6 +473,7 @@ type CreateBookingParams struct {
|
||||
Reference string
|
||||
ManageToken string
|
||||
Notes string
|
||||
Intake string
|
||||
}
|
||||
|
||||
type CreatedBooking struct {
|
||||
@@ -821,11 +829,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
|
||||
FROM bookings
|
||||
WHERE reference = $1
|
||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
@@ -834,11 +842,11 @@ func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (Bo
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||
COALESCE(manage_token, ''), COALESCE(notes, ''), COALESCE(intake::text, '{}'), created_at
|
||||
FROM bookings
|
||||
WHERE id = $1
|
||||
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.Intake, &rec.CreatedAt)
|
||||
return rec, err
|
||||
}
|
||||
|
||||
@@ -1215,6 +1223,7 @@ func (r *MemoryRepository) CreateService(_ context.Context, params CreateService
|
||||
BufferBeforeMinutes: params.BufferBeforeMinutes,
|
||||
BufferAfterMinutes: params.BufferAfterMinutes,
|
||||
PriceCents: params.PriceCents,
|
||||
IntakeFields: params.IntakeFields,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
r.services = append(r.services, service)
|
||||
@@ -1241,6 +1250,9 @@ func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, pa
|
||||
if params.PriceCents != nil {
|
||||
service.PriceCents = *params.PriceCents
|
||||
}
|
||||
if params.IntakeFields != nil {
|
||||
service.IntakeFields = *params.IntakeFields
|
||||
}
|
||||
r.services[i] = service
|
||||
return service, nil
|
||||
}
|
||||
@@ -1749,6 +1761,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
|
||||
Reference: params.Reference,
|
||||
ManageToken: params.ManageToken,
|
||||
Notes: params.Notes,
|
||||
Intake: params.Intake,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
return created, nil
|
||||
@@ -1764,6 +1777,7 @@ func (r *MemoryRepository) seedDemoBookings() {
|
||||
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now},
|
||||
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now},
|
||||
)
|
||||
r.services[0].IntakeFields = `[{"key":"health_notes","label":"Health notes or allergies","type":"text","required":false},{"key":"first_visit","label":"Is this your first visit?","type":"yesno","required":true}]`
|
||||
seed := func(name, email, status string, startsAt time.Time) {
|
||||
r.bookings = append(r.bookings, BookingRecord{
|
||||
ID: uuid.NewString(),
|
||||
@@ -1785,6 +1799,7 @@ func (r *MemoryRepository) seedDemoBookings() {
|
||||
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
|
||||
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
|
||||
seed("Alice Johnson", "[email protected]", "cancelled", now.Add(-74*time.Hour))
|
||||
r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}`
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at"
|
||||
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, intake_fields::text, created_at"
|
||||
|
||||
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
@@ -30,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.IntakeFields,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -53,6 +54,7 @@ func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (Se
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.IntakeFields,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
@@ -61,10 +63,10 @@ func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (Se
|
||||
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
|
||||
var record ServiceRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, intake_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
RETURNING `+serviceColumns+`
|
||||
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
|
||||
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents, params.IntakeFields).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
@@ -72,6 +74,7 @@ func (r *PGRepository) CreateService(ctx context.Context, params CreateServicePa
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.IntakeFields,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
@@ -85,10 +88,11 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para
|
||||
duration_minutes = COALESCE($3, duration_minutes),
|
||||
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
|
||||
buffer_after_minutes = COALESCE($5, buffer_after_minutes),
|
||||
price_cents = COALESCE($6, price_cents)
|
||||
price_cents = COALESCE($6, price_cents),
|
||||
intake_fields = COALESCE($7::jsonb, intake_fields)
|
||||
WHERE id = $1
|
||||
RETURNING `+serviceColumns+`
|
||||
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
|
||||
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents, params.IntakeFields).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.Name,
|
||||
@@ -96,6 +100,7 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para
|
||||
&record.BufferBeforeMinutes,
|
||||
&record.BufferAfterMinutes,
|
||||
&record.PriceCents,
|
||||
&record.IntakeFields,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
|
||||
@@ -26,6 +26,7 @@ type UpcomingBooking struct {
|
||||
Location string `json:"location,omitempty"`
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
@@ -135,10 +136,11 @@ type PublicAvailability struct {
|
||||
}
|
||||
|
||||
type PublicService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
PriceCents int `json:"priceCents"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
PriceCents int `json:"priceCents"`
|
||||
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||
}
|
||||
|
||||
type PublicStaff struct {
|
||||
@@ -163,6 +165,9 @@ type CreateBookingRequest struct {
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
// Intake carries structured intake answers keyed by the service's
|
||||
// declared IntakeField keys; only declared keys are stored.
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
}
|
||||
@@ -295,31 +300,44 @@ type UpdateLocationRequest struct {
|
||||
// SERVICE MODELS
|
||||
// ============================================
|
||||
|
||||
// IntakeField is one structured intake question a service asks at booking
|
||||
// time. Key is a slug (lowercase alnum + underscore) used as the JSON key in
|
||||
// booking intake answers; Type is "text" or "yesno".
|
||||
type IntakeField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
||||
PriceCents int `json:"priceCents"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"`
|
||||
DurationMinutes int `json:"durationMinutes"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
||||
PriceCents int `json:"priceCents"`
|
||||
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreateServiceRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
DurationMinutes int `json:"durationMinutes" binding:"required"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents int `json:"priceCents,omitempty"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
DurationMinutes int `json:"durationMinutes" binding:"required"`
|
||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents int `json:"priceCents,omitempty"`
|
||||
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateServiceRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents *int `json:"priceCents,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents *int `json:"priceCents,omitempty"`
|
||||
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -526,6 +544,7 @@ type CustomerBookingView struct {
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||
// bookings; zero for any other status.
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
-- Schema-light structured intake: services declare a small list of intake
|
||||
-- fields (key/label/type/required); bookings store the customer's answers as
|
||||
-- a JSON object keyed by the declared field keys. Both default to empty so
|
||||
-- existing services/bookings need no backfill.
|
||||
ALTER TABLE services
|
||||
ADD COLUMN intake_fields jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE bookings
|
||||
ADD COLUMN intake jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE services DROP COLUMN IF EXISTS intake_fields;
|
||||
ALTER TABLE bookings DROP COLUMN IF EXISTS intake;
|
||||
@@ -2044,6 +2044,21 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PublicStaff"
|
||||
IntakeField:
|
||||
type: object
|
||||
required: [key, label, type]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Slug key (lowercase alnum + underscore); JSON key in booking intake answers
|
||||
label:
|
||||
type: string
|
||||
description: Customer-facing question text
|
||||
type:
|
||||
type: string
|
||||
enum: [text, yesno]
|
||||
required:
|
||||
type: boolean
|
||||
PublicService:
|
||||
type: object
|
||||
required: [id, name, durationMinutes, priceCents]
|
||||
@@ -2057,6 +2072,10 @@ components:
|
||||
type: integer
|
||||
priceCents:
|
||||
type: integer
|
||||
intakeFields:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IntakeField"
|
||||
PublicStaff:
|
||||
type: object
|
||||
required: [id, displayName]
|
||||
@@ -2094,6 +2113,11 @@ components:
|
||||
format: email
|
||||
notes:
|
||||
type: string
|
||||
intake:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Structured intake answers keyed by the service's declared intakeFields keys
|
||||
startsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -2190,6 +2214,11 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
intake:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Structured intake answers keyed by the service's declared intakeFields keys
|
||||
classSessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -2233,6 +2262,10 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
intake:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||
@@ -2619,6 +2652,11 @@ components:
|
||||
priceCents:
|
||||
type: integer
|
||||
description: Price in the tenant's currency minor units
|
||||
intakeFields:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IntakeField"
|
||||
description: Structured intake questions asked at booking time (max 8)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -2636,6 +2674,10 @@ components:
|
||||
type: integer
|
||||
priceCents:
|
||||
type: integer
|
||||
intakeFields:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IntakeField"
|
||||
StaffMember:
|
||||
type: object
|
||||
required: [id, tenantId, displayName, active]
|
||||
|
||||
Reference in new issue
Block a user