mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user