mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #13 from Dvorinka/feat/structured-intake
Structured per-service intake questions
This commit is contained in:
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"`
|
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||||
CustomerPhone string `json:"customerPhone"`
|
CustomerPhone string `json:"customerPhone"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
StartsAt string `json:"startsAt" binding:"required"`
|
StartsAt string `json:"startsAt" binding:"required"`
|
||||||
EndsAt string `json:"endsAt" binding:"required"`
|
EndsAt string `json:"endsAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -134,6 +135,7 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
|||||||
Location: locationName,
|
Location: locationName,
|
||||||
Status: booking.Status,
|
Status: booking.Status,
|
||||||
Notes: booking.Notes,
|
Notes: booking.Notes,
|
||||||
|
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||||
WaitlistPosition: position,
|
WaitlistPosition: position,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.Public
|
|||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
DurationMinutes: service.DurationMinutes,
|
DurationMinutes: service.DurationMinutes,
|
||||||
PriceCents: service.PriceCents,
|
PriceCents: service.PriceCents,
|
||||||
|
IntakeFields: shared.ParseIntakeFields(service.IntakeFields),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, staff := range staffRecords {
|
for _, staff := range staffRecords {
|
||||||
@@ -283,6 +284,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
|
|
||||||
status := "confirmed"
|
status := "confirmed"
|
||||||
activityLabel := ""
|
activityLabel := ""
|
||||||
|
intakeJSON := "{}"
|
||||||
switch request.BookingMode {
|
switch request.BookingMode {
|
||||||
case "appointment":
|
case "appointment":
|
||||||
if request.ServiceID == nil || request.ClassSessionID != nil {
|
if request.ServiceID == nil || request.ClassSessionID != nil {
|
||||||
@@ -295,6 +297,10 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
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
|
activityLabel = service.Name
|
||||||
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
||||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
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()),
|
Reference: db.Reference("BK", time.Now()),
|
||||||
ManageToken: generateManageToken(),
|
ManageToken: generateManageToken(),
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
|
Intake: intakeJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsExclusionViolation(err) {
|
if db.IsExclusionViolation(err) {
|
||||||
@@ -565,6 +572,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
CustomerName: booking.CustomerName,
|
CustomerName: booking.CustomerName,
|
||||||
CustomerEmail: booking.CustomerEmail,
|
CustomerEmail: booking.CustomerEmail,
|
||||||
Notes: booking.Notes,
|
Notes: booking.Notes,
|
||||||
|
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||||
StartsAt: booking.StartsAt,
|
StartsAt: booking.StartsAt,
|
||||||
EndsAt: booking.EndsAt,
|
EndsAt: booking.EndsAt,
|
||||||
Status: booking.Status,
|
Status: booking.Status,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
"bookra/apps/backend/internal/shared"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -200,6 +201,16 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
|
|||||||
// SERVICE CATALOG MANAGEMENT
|
// 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 {
|
func serviceToDomain(rec db.ServiceRecord) domain.Service {
|
||||||
return domain.Service{
|
return domain.Service{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
@@ -209,6 +220,7 @@ func serviceToDomain(rec db.ServiceRecord) domain.Service {
|
|||||||
BufferBeforeMinutes: rec.BufferBeforeMinutes,
|
BufferBeforeMinutes: rec.BufferBeforeMinutes,
|
||||||
BufferAfterMinutes: rec.BufferAfterMinutes,
|
BufferAfterMinutes: rec.BufferAfterMinutes,
|
||||||
PriceCents: rec.PriceCents,
|
PriceCents: rec.PriceCents,
|
||||||
|
IntakeFields: shared.ParseIntakeFields(rec.IntakeFields),
|
||||||
CreatedAt: rec.CreatedAt,
|
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 {
|
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
|
||||||
return domain.Service{}, err
|
return domain.Service{}, err
|
||||||
}
|
}
|
||||||
|
intakeJSON, err := normalizeIntakeFields(req.IntakeFields)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Service{}, err
|
||||||
|
}
|
||||||
|
|
||||||
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
|
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
|
||||||
TenantID: membership.Tenant.ID,
|
TenantID: membership.Tenant.ID,
|
||||||
@@ -268,6 +284,7 @@ func (s *Service) CreateService(ctx context.Context, principal domain.Principal,
|
|||||||
BufferBeforeMinutes: req.BufferBeforeMinutes,
|
BufferBeforeMinutes: req.BufferBeforeMinutes,
|
||||||
BufferAfterMinutes: req.BufferAfterMinutes,
|
BufferAfterMinutes: req.BufferAfterMinutes,
|
||||||
PriceCents: req.PriceCents,
|
PriceCents: req.PriceCents,
|
||||||
|
IntakeFields: intakeJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Service{}, err
|
return domain.Service{}, err
|
||||||
@@ -303,6 +320,13 @@ func (s *Service) UpdateService(ctx context.Context, principal domain.Principal,
|
|||||||
if req.PriceCents != nil {
|
if req.PriceCents != nil {
|
||||||
params.PriceCents = req.PriceCents
|
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
|
// Validate against the merged result so partial updates cannot produce an
|
||||||
// invalid record (e.g. setting duration to 0 while keeping the name).
|
// 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, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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,
|
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
|
FROM bookings
|
||||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||||
ORDER BY starts_at ASC
|
ORDER BY starts_at ASC
|
||||||
@@ -41,6 +41,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
&record.Reference,
|
&record.Reference,
|
||||||
&record.ManageToken,
|
&record.ManageToken,
|
||||||
&record.Notes,
|
&record.Notes,
|
||||||
|
&record.Intake,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -56,7 +57,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri
|
|||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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,
|
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
|
FROM bookings
|
||||||
WHERE class_session_id = $1
|
WHERE class_session_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@@ -85,6 +86,7 @@ func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID stri
|
|||||||
&record.Reference,
|
&record.Reference,
|
||||||
&record.ManageToken,
|
&record.ManageToken,
|
||||||
&record.Notes,
|
&record.Notes,
|
||||||
|
&record.Intake,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -100,9 +102,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
INSERT INTO bookings (
|
INSERT INTO bookings (
|
||||||
tenant_id, service_id, class_session_id, staff_id, location_id,
|
tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
|
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
|
RETURNING id, reference, status, manage_token
|
||||||
`,
|
`,
|
||||||
params.TenantID,
|
params.TenantID,
|
||||||
@@ -120,6 +122,7 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
|||||||
params.Reference,
|
params.Reference,
|
||||||
params.ManageToken,
|
params.ManageToken,
|
||||||
params.Notes,
|
params.Notes,
|
||||||
|
params.Intake,
|
||||||
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
|
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
|
||||||
return created, err
|
return created, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ type ServiceRecord struct {
|
|||||||
BufferBeforeMinutes int
|
BufferBeforeMinutes int
|
||||||
BufferAfterMinutes int
|
BufferAfterMinutes int
|
||||||
PriceCents 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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +289,7 @@ type CreateServiceParams struct {
|
|||||||
BufferBeforeMinutes int
|
BufferBeforeMinutes int
|
||||||
BufferAfterMinutes int
|
BufferAfterMinutes int
|
||||||
PriceCents int
|
PriceCents int
|
||||||
|
IntakeFields string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pointer fields distinguish "not sent" (keep current value) from an explicit
|
// Pointer fields distinguish "not sent" (keep current value) from an explicit
|
||||||
@@ -296,6 +300,7 @@ type UpdateServiceParams struct {
|
|||||||
BufferBeforeMinutes *int
|
BufferBeforeMinutes *int
|
||||||
BufferAfterMinutes *int
|
BufferAfterMinutes *int
|
||||||
PriceCents *int
|
PriceCents *int
|
||||||
|
IntakeFields *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StaffRecord struct {
|
type StaffRecord struct {
|
||||||
@@ -447,6 +452,8 @@ type BookingRecord struct {
|
|||||||
Reference string
|
Reference string
|
||||||
ManageToken string
|
ManageToken string
|
||||||
Notes string
|
Notes string
|
||||||
|
// Intake is the raw JSON object of structured intake answers.
|
||||||
|
Intake string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +473,7 @@ type CreateBookingParams struct {
|
|||||||
Reference string
|
Reference string
|
||||||
ManageToken string
|
ManageToken string
|
||||||
Notes string
|
Notes string
|
||||||
|
Intake string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatedBooking struct {
|
type CreatedBooking struct {
|
||||||
@@ -821,11 +829,11 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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,
|
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
|
FROM bookings
|
||||||
WHERE reference = $1
|
WHERE reference = $1
|
||||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
`, 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
|
return rec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,11 +842,11 @@ func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (Bo
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
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,
|
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
|
FROM bookings
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
`, 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
|
return rec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,6 +1223,7 @@ func (r *MemoryRepository) CreateService(_ context.Context, params CreateService
|
|||||||
BufferBeforeMinutes: params.BufferBeforeMinutes,
|
BufferBeforeMinutes: params.BufferBeforeMinutes,
|
||||||
BufferAfterMinutes: params.BufferAfterMinutes,
|
BufferAfterMinutes: params.BufferAfterMinutes,
|
||||||
PriceCents: params.PriceCents,
|
PriceCents: params.PriceCents,
|
||||||
|
IntakeFields: params.IntakeFields,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
r.services = append(r.services, service)
|
r.services = append(r.services, service)
|
||||||
@@ -1241,6 +1250,9 @@ func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, pa
|
|||||||
if params.PriceCents != nil {
|
if params.PriceCents != nil {
|
||||||
service.PriceCents = *params.PriceCents
|
service.PriceCents = *params.PriceCents
|
||||||
}
|
}
|
||||||
|
if params.IntakeFields != nil {
|
||||||
|
service.IntakeFields = *params.IntakeFields
|
||||||
|
}
|
||||||
r.services[i] = service
|
r.services[i] = service
|
||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
@@ -1749,6 +1761,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
|
|||||||
Reference: params.Reference,
|
Reference: params.Reference,
|
||||||
ManageToken: params.ManageToken,
|
ManageToken: params.ManageToken,
|
||||||
Notes: params.Notes,
|
Notes: params.Notes,
|
||||||
|
Intake: params.Intake,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
return created, nil
|
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: 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},
|
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) {
|
seed := func(name, email, status string, startsAt time.Time) {
|
||||||
r.bookings = append(r.bookings, BookingRecord{
|
r.bookings = append(r.bookings, BookingRecord{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
@@ -1785,6 +1799,7 @@ func (r *MemoryRepository) seedDemoBookings() {
|
|||||||
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
|
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
|
||||||
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
|
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
|
||||||
seed("Alice Johnson", "[email protected]", "cancelled", now.Add(-74*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 {
|
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"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) {
|
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
@@ -30,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
|
|||||||
&record.BufferBeforeMinutes,
|
&record.BufferBeforeMinutes,
|
||||||
&record.BufferAfterMinutes,
|
&record.BufferAfterMinutes,
|
||||||
&record.PriceCents,
|
&record.PriceCents,
|
||||||
|
&record.IntakeFields,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -53,6 +54,7 @@ func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (Se
|
|||||||
&record.BufferBeforeMinutes,
|
&record.BufferBeforeMinutes,
|
||||||
&record.BufferAfterMinutes,
|
&record.BufferAfterMinutes,
|
||||||
&record.PriceCents,
|
&record.PriceCents,
|
||||||
|
&record.IntakeFields,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
)
|
)
|
||||||
return record, err
|
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) {
|
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
|
||||||
var record ServiceRecord
|
var record ServiceRecord
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||||
RETURNING `+serviceColumns+`
|
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.ID,
|
||||||
&record.TenantID,
|
&record.TenantID,
|
||||||
&record.Name,
|
&record.Name,
|
||||||
@@ -72,6 +74,7 @@ func (r *PGRepository) CreateService(ctx context.Context, params CreateServicePa
|
|||||||
&record.BufferBeforeMinutes,
|
&record.BufferBeforeMinutes,
|
||||||
&record.BufferAfterMinutes,
|
&record.BufferAfterMinutes,
|
||||||
&record.PriceCents,
|
&record.PriceCents,
|
||||||
|
&record.IntakeFields,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
)
|
)
|
||||||
return record, err
|
return record, err
|
||||||
@@ -85,10 +88,11 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para
|
|||||||
duration_minutes = COALESCE($3, duration_minutes),
|
duration_minutes = COALESCE($3, duration_minutes),
|
||||||
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
|
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
|
||||||
buffer_after_minutes = COALESCE($5, buffer_after_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
|
WHERE id = $1
|
||||||
RETURNING `+serviceColumns+`
|
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.ID,
|
||||||
&record.TenantID,
|
&record.TenantID,
|
||||||
&record.Name,
|
&record.Name,
|
||||||
@@ -96,6 +100,7 @@ func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, para
|
|||||||
&record.BufferBeforeMinutes,
|
&record.BufferBeforeMinutes,
|
||||||
&record.BufferAfterMinutes,
|
&record.BufferAfterMinutes,
|
||||||
&record.PriceCents,
|
&record.PriceCents,
|
||||||
|
&record.IntakeFields,
|
||||||
&record.CreatedAt,
|
&record.CreatedAt,
|
||||||
)
|
)
|
||||||
return record, err
|
return record, err
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type UpcomingBooking struct {
|
|||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
StaffName string `json:"staffName,omitempty"`
|
StaffName string `json:"staffName,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -135,10 +136,11 @@ type PublicAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicService struct {
|
type PublicService struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DurationMinutes int `json:"durationMinutes"`
|
DurationMinutes int `json:"durationMinutes"`
|
||||||
PriceCents int `json:"priceCents"`
|
PriceCents int `json:"priceCents"`
|
||||||
|
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicStaff struct {
|
type PublicStaff struct {
|
||||||
@@ -163,6 +165,9 @@ type CreateBookingRequest struct {
|
|||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
CustomerPhone string `json:"customerPhone,omitempty"`
|
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||||
Notes string `json:"notes"`
|
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"`
|
StartsAt string `json:"startsAt"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
}
|
}
|
||||||
@@ -295,31 +300,44 @@ type UpdateLocationRequest struct {
|
|||||||
// SERVICE MODELS
|
// 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 {
|
type Service struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DurationMinutes int `json:"durationMinutes"`
|
DurationMinutes int `json:"durationMinutes"`
|
||||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
|
||||||
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
BufferAfterMinutes int `json:"bufferAfterMinutes"`
|
||||||
PriceCents int `json:"priceCents"`
|
PriceCents int `json:"priceCents"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
DurationMinutes int `json:"durationMinutes" binding:"required"`
|
DurationMinutes int `json:"durationMinutes" binding:"required"`
|
||||||
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
|
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
|
||||||
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
|
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
|
||||||
PriceCents int `json:"priceCents,omitempty"`
|
PriceCents int `json:"priceCents,omitempty"`
|
||||||
|
IntakeFields []IntakeField `json:"intakeFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||||
PriceCents *int `json:"priceCents,omitempty"`
|
PriceCents *int `json:"priceCents,omitempty"`
|
||||||
|
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -526,6 +544,7 @@ type CustomerBookingView struct {
|
|||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||||
// bookings; zero for any other status.
|
// bookings; zero for any other status.
|
||||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
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
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/PublicStaff"
|
$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:
|
PublicService:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, durationMinutes, priceCents]
|
required: [id, name, durationMinutes, priceCents]
|
||||||
@@ -2057,6 +2072,10 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
priceCents:
|
priceCents:
|
||||||
type: integer
|
type: integer
|
||||||
|
intakeFields:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/IntakeField"
|
||||||
PublicStaff:
|
PublicStaff:
|
||||||
type: object
|
type: object
|
||||||
required: [id, displayName]
|
required: [id, displayName]
|
||||||
@@ -2094,6 +2113,11 @@ components:
|
|||||||
format: email
|
format: email
|
||||||
notes:
|
notes:
|
||||||
type: string
|
type: string
|
||||||
|
intake:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Structured intake answers keyed by the service's declared intakeFields keys
|
||||||
startsAt:
|
startsAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -2190,6 +2214,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
notes:
|
notes:
|
||||||
type: string
|
type: string
|
||||||
|
intake:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Structured intake answers keyed by the service's declared intakeFields keys
|
||||||
classSessionId:
|
classSessionId:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
@@ -2233,6 +2262,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
notes:
|
notes:
|
||||||
type: string
|
type: string
|
||||||
|
intake:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
waitlistPosition:
|
waitlistPosition:
|
||||||
type: integer
|
type: integer
|
||||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||||
@@ -2619,6 +2652,11 @@ components:
|
|||||||
priceCents:
|
priceCents:
|
||||||
type: integer
|
type: integer
|
||||||
description: Price in the tenant's currency minor units
|
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:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -2636,6 +2674,10 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
priceCents:
|
priceCents:
|
||||||
type: integer
|
type: integer
|
||||||
|
intakeFields:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/IntakeField"
|
||||||
StaffMember:
|
StaffMember:
|
||||||
type: object
|
type: object
|
||||||
required: [id, tenantId, displayName, active]
|
required: [id, tenantId, displayName, active]
|
||||||
|
|||||||
@@ -468,6 +468,13 @@ const dictionaries = {
|
|||||||
"dashboard.service.edit": "Upravit službu",
|
"dashboard.service.edit": "Upravit službu",
|
||||||
"dashboard.service.name": "Název služby",
|
"dashboard.service.name": "Název služby",
|
||||||
"dashboard.service.namePlaceholder": "např. Dámský střih",
|
"dashboard.service.namePlaceholder": "např. Dámský střih",
|
||||||
|
"dashboard.service.intake": "Dotazník před návštěvou",
|
||||||
|
"dashboard.service.intakeAdd": "Přidat otázku",
|
||||||
|
"dashboard.service.intakeLabel": "Otázka pro zákazníka",
|
||||||
|
"dashboard.service.intakeText": "Text",
|
||||||
|
"dashboard.service.intakeYesNo": "Ano/Ne",
|
||||||
|
"dashboard.service.intakeRequired": "Povinná",
|
||||||
|
"dashboard.service.intakeHint": "Zákazníci odpoví při rezervaci. Odpovědi uvidíte v detailu rezervace.",
|
||||||
"dashboard.service.duration": "Délka (min)",
|
"dashboard.service.duration": "Délka (min)",
|
||||||
"dashboard.service.bufferBefore": "Přestávka před",
|
"dashboard.service.bufferBefore": "Přestávka před",
|
||||||
"dashboard.service.bufferAfter": "Přestávka po",
|
"dashboard.service.bufferAfter": "Přestávka po",
|
||||||
@@ -519,6 +526,7 @@ const dictionaries = {
|
|||||||
"dashboard.bookingModal.status": "Stav",
|
"dashboard.bookingModal.status": "Stav",
|
||||||
"dashboard.bookingModal.reference": "Reference",
|
"dashboard.bookingModal.reference": "Reference",
|
||||||
"dashboard.bookingModal.notes": "Poznámky",
|
"dashboard.bookingModal.notes": "Poznámky",
|
||||||
|
"dashboard.bookingModal.intake": "Odpovědi z dotazníku",
|
||||||
"dashboard.bookingModal.reschedule": "Přeplánovat",
|
"dashboard.bookingModal.reschedule": "Přeplánovat",
|
||||||
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
|
"dashboard.bookingModal.confirmCancel": "Opravdu chcete zrušit tuto rezervaci?",
|
||||||
"dashboard.bookingModal.createdAt": "Vytvořeno",
|
"dashboard.bookingModal.createdAt": "Vytvořeno",
|
||||||
@@ -563,6 +571,8 @@ const dictionaries = {
|
|||||||
"booking.customer.email": "E-mail",
|
"booking.customer.email": "E-mail",
|
||||||
"booking.customer.phone": "Telefon",
|
"booking.customer.phone": "Telefon",
|
||||||
"booking.customer.notes": "Poznámka",
|
"booking.customer.notes": "Poznámka",
|
||||||
|
"booking.intakeYes": "Ano",
|
||||||
|
"booking.intakeNo": "Ne",
|
||||||
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
"booking.customerRequired": "Před rezervací vyplňte jméno a e-mail.",
|
||||||
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
"booking.failed": "Rezervaci se nepodařilo vytvořit",
|
||||||
"booking.created": "Rezervace vytvořena",
|
"booking.created": "Rezervace vytvořena",
|
||||||
@@ -1087,6 +1097,13 @@ const dictionaries = {
|
|||||||
"dashboard.service.edit": "Edit service",
|
"dashboard.service.edit": "Edit service",
|
||||||
"dashboard.service.name": "Service name",
|
"dashboard.service.name": "Service name",
|
||||||
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
|
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
|
||||||
|
"dashboard.service.intake": "Pre-visit questions",
|
||||||
|
"dashboard.service.intakeAdd": "Add question",
|
||||||
|
"dashboard.service.intakeLabel": "Question for the customer",
|
||||||
|
"dashboard.service.intakeText": "Text",
|
||||||
|
"dashboard.service.intakeYesNo": "Yes/No",
|
||||||
|
"dashboard.service.intakeRequired": "Required",
|
||||||
|
"dashboard.service.intakeHint": "Customers answer these when booking. Answers appear in the booking detail.",
|
||||||
"dashboard.service.duration": "Duration (min)",
|
"dashboard.service.duration": "Duration (min)",
|
||||||
"dashboard.service.bufferBefore": "Buffer before",
|
"dashboard.service.bufferBefore": "Buffer before",
|
||||||
"dashboard.service.bufferAfter": "Buffer after",
|
"dashboard.service.bufferAfter": "Buffer after",
|
||||||
@@ -1138,6 +1155,7 @@ const dictionaries = {
|
|||||||
"dashboard.bookingModal.status": "Status",
|
"dashboard.bookingModal.status": "Status",
|
||||||
"dashboard.bookingModal.reference": "Reference",
|
"dashboard.bookingModal.reference": "Reference",
|
||||||
"dashboard.bookingModal.notes": "Notes",
|
"dashboard.bookingModal.notes": "Notes",
|
||||||
|
"dashboard.bookingModal.intake": "Intake answers",
|
||||||
"dashboard.bookingModal.reschedule": "Reschedule",
|
"dashboard.bookingModal.reschedule": "Reschedule",
|
||||||
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
|
"dashboard.bookingModal.confirmCancel": "Are you sure you want to cancel this booking?",
|
||||||
"dashboard.bookingModal.createdAt": "Created",
|
"dashboard.bookingModal.createdAt": "Created",
|
||||||
@@ -1182,6 +1200,8 @@ const dictionaries = {
|
|||||||
"booking.customer.email": "Email",
|
"booking.customer.email": "Email",
|
||||||
"booking.customer.phone": "Phone",
|
"booking.customer.phone": "Phone",
|
||||||
"booking.customer.notes": "Note",
|
"booking.customer.notes": "Note",
|
||||||
|
"booking.intakeYes": "Yes",
|
||||||
|
"booking.intakeNo": "No",
|
||||||
"booking.customerRequired": "Add your name and email before booking.",
|
"booking.customerRequired": "Add your name and email before booking.",
|
||||||
"booking.failed": "Booking failed",
|
"booking.failed": "Booking failed",
|
||||||
"booking.created": "Booking created",
|
"booking.created": "Booking created",
|
||||||
|
|||||||
@@ -391,6 +391,24 @@ export function BookingManageRoute() {
|
|||||||
<p class="text-sm text-ink mt-1">{b().notes}</p>
|
<p class="text-sm text-ink mt-1">{b().notes}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={b().intake && Object.keys(b().intake).length > 0}>
|
||||||
|
<div class="mt-3 pt-3 border-t border-border">
|
||||||
|
<p class="text-sm text-ink-muted">{i18n.t("dashboard.bookingModal.intake")}</p>
|
||||||
|
<div class="space-y-1 mt-1">
|
||||||
|
<For each={Object.entries(b().intake)}>
|
||||||
|
{([key, raw]) => {
|
||||||
|
const value = String(raw);
|
||||||
|
return (
|
||||||
|
<p class="text-sm text-ink">
|
||||||
|
<span class="text-ink-muted">{key.replace(/_/g, " ")}: </span>
|
||||||
|
{value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
|||||||
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" },
|
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" },
|
||||||
{ id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" },
|
{ id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" },
|
||||||
// today
|
// today
|
||||||
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "" },
|
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" } },
|
||||||
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" },
|
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" },
|
||||||
// upcoming
|
// upcoming
|
||||||
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
|
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
import { For, Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||||
import { A, useLocation } from "@solidjs/router";
|
import { A, useLocation } from "@solidjs/router";
|
||||||
import { useTheme } from "../../providers/theme-provider";
|
import { useTheme } from "../../providers/theme-provider";
|
||||||
import { BookraCharacter } from "../../components/bookra-character";
|
import { BookraCharacter } from "../../components/bookra-character";
|
||||||
@@ -329,6 +329,24 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
<p class="text-ink text-sm">{data.selectedBooking()?.notes}</p>
|
<p class="text-ink text-sm">{data.selectedBooking()?.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={data.selectedBooking()?.intake && Object.keys(data.selectedBooking()!.intake!).length > 0}>
|
||||||
|
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||||
|
<p class="text-xs text-ink-muted uppercase tracking-wider mb-2">{data.i18n.t("dashboard.bookingModal.intake")}</p>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<For each={Object.entries(data.selectedBooking()!.intake!)}>
|
||||||
|
{([key, raw]) => {
|
||||||
|
const value = String(raw);
|
||||||
|
return (
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="text-xs text-ink-muted">{key.replace(/_/g, " ")}</span>
|
||||||
|
<span class="text-sm text-ink">{value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||||
<div class="p-6 border-t border-border space-y-3">
|
<div class="p-6 border-t border-border space-y-3">
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { For, Show, createResource, createSignal } from "solid-js";
|
import { For, Show, createResource, createSignal } from "solid-js";
|
||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Select } from "../../components/ui/select";
|
||||||
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||||
|
|
||||||
|
interface IntakeDraft {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakeKey = (label: string) =>
|
||||||
|
label
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
|
||||||
interface ServiceForm {
|
interface ServiceForm {
|
||||||
name: string;
|
name: string;
|
||||||
durationMinutes: string;
|
durationMinutes: string;
|
||||||
bufferBeforeMinutes: string;
|
bufferBeforeMinutes: string;
|
||||||
bufferAfterMinutes: string;
|
bufferAfterMinutes: string;
|
||||||
price: string;
|
price: string;
|
||||||
|
intakeFields: IntakeDraft[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" };
|
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0", intakeFields: [] };
|
||||||
|
|
||||||
function ServicesPage() {
|
function ServicesPage() {
|
||||||
const data = useDashboardData();
|
const data = useDashboardData();
|
||||||
@@ -32,7 +51,18 @@ function ServicesPage() {
|
|||||||
if (!bearer) return [];
|
if (!bearer) return [];
|
||||||
if (bearer.startsWith("demo.")) {
|
if (bearer.startsWith("demo.")) {
|
||||||
return [
|
return [
|
||||||
{ id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000 },
|
{
|
||||||
|
id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000,
|
||||||
|
intakeFields: cs()
|
||||||
|
? [
|
||||||
|
{ key: "zdravotni_poznamky", label: "Zdravotní poznámky nebo alergie", type: "text", required: false },
|
||||||
|
{ key: "prvni_navsteva", label: "Je to vaše první návštěva?", type: "yesno", required: true },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
|
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
|
||||||
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
|
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
|
||||||
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
|
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
|
||||||
@@ -64,10 +94,24 @@ function ServicesPage() {
|
|||||||
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
|
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
|
||||||
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
|
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
|
||||||
price: String((service.priceCents ?? 0) / 100),
|
price: String((service.priceCents ?? 0) / 100),
|
||||||
|
intakeFields: (service.intakeFields ?? []).map((f: any) => ({ key: f.key ?? "", label: f.label ?? "", type: f.type ?? "text", required: !!f.required })),
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setIntake = (i: number, patch: Partial<IntakeDraft>) => {
|
||||||
|
const fields = form().intakeFields.slice();
|
||||||
|
fields[i] = { ...fields[i], ...patch };
|
||||||
|
setField({ intakeFields: fields });
|
||||||
|
};
|
||||||
|
const addIntake = () => {
|
||||||
|
if (form().intakeFields.length >= 8) return;
|
||||||
|
setField({ intakeFields: [...form().intakeFields, { key: "", label: "", type: "text", required: false }] });
|
||||||
|
};
|
||||||
|
const removeIntake = (i: number) => {
|
||||||
|
setField({ intakeFields: form().intakeFields.filter((_, j) => j !== i) });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const bearer = data.token();
|
const bearer = data.token();
|
||||||
const name = form().name.trim();
|
const name = form().name.trim();
|
||||||
@@ -85,6 +129,14 @@ function ServicesPage() {
|
|||||||
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
|
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
|
||||||
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
|
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
|
||||||
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
|
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
|
||||||
|
intakeFields: form().intakeFields
|
||||||
|
.filter((f) => f.label.trim())
|
||||||
|
.map((f) => ({
|
||||||
|
key: f.key || intakeKey(f.label),
|
||||||
|
label: f.label.trim(),
|
||||||
|
type: (f.type === "yesno" ? "yesno" : "text") as "text" | "yesno",
|
||||||
|
required: f.required,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const id = editingId();
|
const id = editingId();
|
||||||
const response = id
|
const response = id
|
||||||
@@ -142,6 +194,42 @@ function ServicesPage() {
|
|||||||
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
|
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
|
||||||
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
|
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-border/60">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-sm font-medium text-ink">{data.i18n.t("dashboard.service.intake")}</p>
|
||||||
|
<Show when={form().intakeFields.length < 8}>
|
||||||
|
<button onClick={addIntake} class="text-xs text-accent hover:underline flex items-center gap-1"><PlusIcon /> {data.i18n.t("dashboard.service.intakeAdd")}</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={form().intakeFields.length > 0} fallback={
|
||||||
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.service.intakeHint")}</p>
|
||||||
|
}>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={form().intakeFields}>
|
||||||
|
{(field, i) => (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<Input type="text" value={field.label} onInput={(e) => setIntake(i(), { label: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.intakeLabel")} />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={field.type}
|
||||||
|
onChange={(v) => setIntake(i(), { type: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "text", label: data.i18n.t("dashboard.service.intakeText") },
|
||||||
|
{ value: "yesno", label: data.i18n.t("dashboard.service.intakeYesNo") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-ink-muted whitespace-nowrap cursor-pointer">
|
||||||
|
<input type="checkbox" checked={field.required} onChange={(e) => setIntake(i(), { required: e.currentTarget.checked })} class="rounded accent-accent" />
|
||||||
|
{data.i18n.t("dashboard.service.intakeRequired")}
|
||||||
|
</label>
|
||||||
|
<button onClick={() => removeIntake(i())} class="p-1.5 text-ink-subtle hover:text-red-600 rounded-lg" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<div class="flex gap-3 mt-4">
|
<div class="flex gap-3 mt-4">
|
||||||
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
|
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
|
||||||
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function PublicBookingRoute() {
|
|||||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||||
const [customerPhone, setCustomerPhone] = createSignal("");
|
const [customerPhone, setCustomerPhone] = createSignal("");
|
||||||
const [notes, setNotes] = createSignal("");
|
const [notes, setNotes] = createSignal("");
|
||||||
|
const [intakeAnswers, setIntakeAnswers] = createSignal<Record<string, string>>({});
|
||||||
const [highlightContact, setHighlightContact] = createSignal(false);
|
const [highlightContact, setHighlightContact] = createSignal(false);
|
||||||
const [selectedServiceId, setSelectedServiceId] = createSignal<string | null>(null);
|
const [selectedServiceId, setSelectedServiceId] = createSignal<string | null>(null);
|
||||||
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
|
const [selectedStaffId, setSelectedStaffId] = createSignal<string | null>(null);
|
||||||
@@ -68,6 +69,9 @@ export function PublicBookingRoute() {
|
|||||||
|
|
||||||
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
const services = createMemo<PublicService[]>(() => catalog.latest?.data?.services ?? []);
|
||||||
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
const staff = createMemo<PublicStaff[]>(() => catalog.latest?.data?.staff ?? []);
|
||||||
|
const intakeFields = createMemo(
|
||||||
|
() => services().find((s) => s.id === selectedServiceId())?.intakeFields ?? [],
|
||||||
|
);
|
||||||
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class"));
|
||||||
const classDays = createMemo(() => {
|
const classDays = createMemo(() => {
|
||||||
const groups = new Map<string, any[]>();
|
const groups = new Map<string, any[]>();
|
||||||
@@ -155,6 +159,7 @@ export function PublicBookingRoute() {
|
|||||||
customerEmail: customerEmail().trim(),
|
customerEmail: customerEmail().trim(),
|
||||||
customerPhone: customerPhone().trim() || undefined,
|
customerPhone: customerPhone().trim() || undefined,
|
||||||
notes: notes().trim(),
|
notes: notes().trim(),
|
||||||
|
intake: intakeFields().length > 0 ? intakeAnswers() : undefined,
|
||||||
startsAt: slot.startsAt,
|
startsAt: slot.startsAt,
|
||||||
endsAt: slot.endsAt,
|
endsAt: slot.endsAt,
|
||||||
},
|
},
|
||||||
@@ -241,6 +246,43 @@ export function PublicBookingRoute() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={notes()}
|
value={notes()}
|
||||||
/>
|
/>
|
||||||
|
<Show when={intakeFields().length > 0}>
|
||||||
|
<div class="flex flex-col gap-3 border-t border-border/60 pt-3">
|
||||||
|
<For each={intakeFields()}>
|
||||||
|
{(field) => (
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-ink-muted">
|
||||||
|
{field.label}
|
||||||
|
<Show when={field.required}>
|
||||||
|
<span class="text-red-500"> *</span>
|
||||||
|
</Show>
|
||||||
|
</label>
|
||||||
|
<Show
|
||||||
|
when={field.type === "yesno"}
|
||||||
|
fallback={
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
value={intakeAnswers()[field.key] ?? ""}
|
||||||
|
onInput={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="form-input"
|
||||||
|
value={intakeAnswers()[field.key] ?? ""}
|
||||||
|
onChange={(e) => setIntakeAnswers({ ...intakeAnswers(), [field.key]: e.currentTarget.value })}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="yes">{i18n.t("booking.intakeYes")}</option>
|
||||||
|
<option value="no">{i18n.t("booking.intakeNo")}</option>
|
||||||
|
</select>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1413,6 +1413,10 @@ export interface components {
|
|||||||
customerName: string;
|
customerName: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
endsAt: string;
|
endsAt: string;
|
||||||
|
/** @description Structured intake answers keyed by the service's declared intakeFields keys */
|
||||||
|
intake?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
locationId?: string;
|
locationId?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -1475,6 +1479,9 @@ export interface components {
|
|||||||
customerName?: string;
|
customerName?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
endsAt?: string;
|
endsAt?: string;
|
||||||
|
intake?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
location?: string;
|
location?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
@@ -1547,6 +1554,15 @@ export interface components {
|
|||||||
/** @example ok */
|
/** @example ok */
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
IntakeField: {
|
||||||
|
/** @description Slug key (lowercase alnum + underscore); JSON key in booking intake answers */
|
||||||
|
key: string;
|
||||||
|
/** @description Customer-facing question text */
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: "text" | "yesno";
|
||||||
|
};
|
||||||
Location: {
|
Location: {
|
||||||
address?: string;
|
address?: string;
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
@@ -1626,6 +1642,7 @@ export interface components {
|
|||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
|
intakeFields?: components["schemas"]["IntakeField"][];
|
||||||
name: string;
|
name: string;
|
||||||
priceCents: number;
|
priceCents: number;
|
||||||
};
|
};
|
||||||
@@ -1645,6 +1662,8 @@ export interface components {
|
|||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** @description Structured intake questions asked at booking time (max 8) */
|
||||||
|
intakeFields?: components["schemas"]["IntakeField"][];
|
||||||
name: string;
|
name: string;
|
||||||
/** @description Price in the tenant's currency minor units */
|
/** @description Price in the tenant's currency minor units */
|
||||||
priceCents: number;
|
priceCents: number;
|
||||||
@@ -1822,6 +1841,10 @@ export interface components {
|
|||||||
endsAt?: string;
|
endsAt?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** @description Structured intake answers keyed by the service's declared intakeFields keys */
|
||||||
|
intake?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
label?: string;
|
label?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -1883,6 +1906,7 @@ export interface components {
|
|||||||
bufferAfterMinutes?: number;
|
bufferAfterMinutes?: number;
|
||||||
bufferBeforeMinutes?: number;
|
bufferBeforeMinutes?: number;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
|
intakeFields?: components["schemas"]["IntakeField"][];
|
||||||
name: string;
|
name: string;
|
||||||
priceCents?: number;
|
priceCents?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-1
@@ -212,6 +212,14 @@
|
|||||||
- dashboard: "Opakující se lekce" card between templates and sessions on the classes tab — rule form (template, weekday, time, optional location) + rule list; sessions expose `scheduleId` so rule-generated sessions are identifiable
|
- dashboard: "Opakující se lekce" card between templates and sessions on the classes tab — rule form (template, weekday, time, optional location) + rule list; sessions expose `scheduleId` so rule-generated sessions are identifiable
|
||||||
- verified live: seeded Wed 10:00 + Sat 09:30 rules generated 8 sessions at correct local times; create → 4 Monday sessions materialized immediately; delete → rule gone, sessions remain; dayOfWeek 9 rejected; public availability converts local→UTC correctly; focused tests cover materialization, idempotency, validation
|
- verified live: seeded Wed 10:00 + Sat 09:30 rules generated 8 sessions at correct local times; create → 4 Monday sessions materialized immediately; delete → rule gone, sessions remain; dayOfWeek 9 rejected; public availability converts local→UTC correctly; focused tests cover materialization, idempotency, validation
|
||||||
- remaining: live staging validation on Neon + pgx repository path; no bulk series edit (delete-and-recreate by design)
|
- remaining: live staging validation on Neon + pgx repository path; no bulk series edit (delete-and-recreate by design)
|
||||||
|
- `[done/partial]` Milestone 30: Structured intake questions
|
||||||
|
- services declare up to 8 intake fields (`text` / `yesno`, required flag); `services.intake_fields` JSONB (migration 00019), bookings gain `intake` JSONB for canonical answers
|
||||||
|
- `shared/intake.go` is the single normalization point: slug keys (≤40), labels ≤80, duplicate/unknown keys rejected on declaration, unknown answer keys silently dropped, answers trimmed ≤500, yes/no normalized
|
||||||
|
- booking create validates against the service's declared fields — missing required → 400 naming the field label; catalog create/update reuses the same normalizer
|
||||||
|
- intake surfaces: public catalog (`PublicService.intakeFields`), dashboard upcoming bookings (`UpcomingBooking.intake`), customer manage view (`CustomerBookingView.intake`)
|
||||||
|
- services page gains a compact intake editor (label + type + required per row, max 8, client-side slug key generation mirroring the backend); public form renders declared questions under contact details with required markers; dashboard booking modal and manage view list answers (yes/no localized, keys shown normalized — labels are not stored with answers by design)
|
||||||
|
- verified live + in-browser: catalog exposes fields, booking with unknown key stores only declared answers, missing required → 400, UI submit → stored answers visible in dashboard summary and manage view; focused `internal/shared` tests cover normalization edge cases
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path; labels shown to staff are key-derived (service lookup for original labels is a possible refinement)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
@@ -463,7 +471,7 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
|
|||||||
|
|
||||||
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
||||||
- **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround.
|
- **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround.
|
||||||
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
- **Intake answer surfacing**: M30 stores answers keyed by slug, so staff see normalized keys rather than original labels; joining the service's declared labels into the booking view (and filtering/reporting on yes-no answers) would complete the loop without changing the schema.
|
||||||
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
|
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
|
||||||
- **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe.
|
- **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe.
|
||||||
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
|
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
|
||||||
|
|||||||
Reference in new issue
Block a user