Files
Bookra/apps/backend/internal/db/services.go
T
Tomas DvorakandDevin 33b6e2a2a6 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>
2026-09-18 18:04:32 +02:00

178 lines
4.9 KiB
Go

package db
import (
"context"
"time"
)
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, `
SELECT `+serviceColumns+`
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServiceRecord
for rows.Next() {
var record ServiceRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.IntakeFields,
&record.CreatedAt,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
SELECT `+serviceColumns+`
FROM services
WHERE id = $1
`, serviceID).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.IntakeFields,
&record.CreatedAt,
)
return record, err
}
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, 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, params.IntakeFields).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.IntakeFields,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
UPDATE services
SET name = COALESCE($2, name),
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),
intake_fields = COALESCE($7::jsonb, intake_fields)
WHERE id = $1
RETURNING `+serviceColumns+`
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents, params.IntakeFields).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.IntakeFields,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID)
return err
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AvailabilityRuleRecord
for rows.Next() {
var record AvailabilityRuleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.StaffID,
&record.DayOfWeek,
&record.StartsLocal,
&record.EndsLocal,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity, cs.schedule_id
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
ORDER BY cs.starts_at ASC
LIMIT $3
`, tenantID, from, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
&record.ScheduleID,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}