mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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>
331 lines
11 KiB
Go
331 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
|
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, ''), COALESCE(intake::text, '{}'), created_at
|
|
FROM bookings
|
|
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
|
ORDER BY starts_at ASC
|
|
`, tenantID, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []BookingRecord
|
|
for rows.Next() {
|
|
var record BookingRecord
|
|
if err := rows.Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.ServiceID,
|
|
&record.ClassSessionID,
|
|
&record.StaffID,
|
|
&record.LocationID,
|
|
&record.CustomerName,
|
|
&record.CustomerEmail,
|
|
&record.CustomerPhone,
|
|
&record.StartsAt,
|
|
&record.EndsAt,
|
|
&record.Status,
|
|
&record.Reference,
|
|
&record.ManageToken,
|
|
&record.Notes,
|
|
&record.Intake,
|
|
&record.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
// ListBookingsBySession returns every booking on a class session in
|
|
// creation order — the ordering that defines the waitlist queue.
|
|
func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) {
|
|
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, ''), COALESCE(intake::text, '{}'), created_at
|
|
FROM bookings
|
|
WHERE class_session_id = $1
|
|
ORDER BY created_at ASC
|
|
`, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []BookingRecord
|
|
for rows.Next() {
|
|
var record BookingRecord
|
|
if err := rows.Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.ServiceID,
|
|
&record.ClassSessionID,
|
|
&record.StaffID,
|
|
&record.LocationID,
|
|
&record.CustomerName,
|
|
&record.CustomerEmail,
|
|
&record.CustomerPhone,
|
|
&record.StartsAt,
|
|
&record.EndsAt,
|
|
&record.Status,
|
|
&record.Reference,
|
|
&record.ManageToken,
|
|
&record.Notes,
|
|
&record.Intake,
|
|
&record.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
|
var created CreatedBooking
|
|
err := r.pool.QueryRow(ctx, `
|
|
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, intake
|
|
)
|
|
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,
|
|
params.ServiceID,
|
|
params.ClassSessionID,
|
|
params.StaffID,
|
|
params.LocationID,
|
|
params.BookingMode,
|
|
params.CustomerName,
|
|
params.CustomerEmail,
|
|
params.CustomerPhone,
|
|
params.StartsAt,
|
|
params.EndsAt,
|
|
params.Status,
|
|
params.Reference,
|
|
params.ManageToken,
|
|
params.Notes,
|
|
params.Intake,
|
|
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
|
|
return created, err
|
|
}
|
|
|
|
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
|
|
return err
|
|
}
|
|
|
|
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
|
|
// Entries carry no booking_id, so the session + customer email pair is the
|
|
// practical key.
|
|
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE waitlist_entries SET status = 'resolved'
|
|
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
|
|
`, tenantID, sessionID, customerEmail)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
|
|
rj.booking_id, rj.channel, rj.scheduled_for,
|
|
b.customer_name, b.customer_email, b.customer_phone, b.reference, b.starts_at, rj.status, b.status
|
|
FROM reminder_jobs rj
|
|
INNER JOIN bookings b ON b.id = rj.booking_id
|
|
INNER JOIN tenants t ON t.id = rj.tenant_id
|
|
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
|
|
ORDER BY rj.scheduled_for ASC
|
|
LIMIT $2
|
|
`, dueBefore, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []ReminderJobRecord
|
|
for rows.Next() {
|
|
var record ReminderJobRecord
|
|
if err := rows.Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.TenantName,
|
|
&record.Locale,
|
|
&record.Timezone,
|
|
&record.BookingID,
|
|
&record.Channel,
|
|
&record.ScheduledFor,
|
|
&record.CustomerName,
|
|
&record.CustomerEmail,
|
|
&record.CustomerPhone,
|
|
&record.Reference,
|
|
&record.StartsAt,
|
|
&record.Status,
|
|
&record.BookingStatus,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE reminder_jobs
|
|
SET status = $2, dispatched_at = $3
|
|
WHERE id = $1
|
|
`, reminderJobID, status, dispatchedAt)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO notification_delivery_logs (
|
|
tenant_id, reminder_job_id, channel, provider, recipient,
|
|
delivery_status, external_id, error_message
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
|
|
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
|
|
params.Status, params.ExternalID, params.ErrorMessage)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
|
|
var metrics DashboardMetrics
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
|
|
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
|
|
COALESCE(
|
|
ROUND(
|
|
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
|
|
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
|
|
),
|
|
0
|
|
)::integer AS utilization_percent,
|
|
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count,
|
|
COUNT(DISTINCT customer_email) FILTER (
|
|
WHERE starts_at >= $2 AND starts_at < $3
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM bookings earlier
|
|
WHERE earlier.tenant_id = bookings.tenant_id
|
|
AND earlier.customer_email = bookings.customer_email
|
|
AND earlier.starts_at < $2
|
|
)
|
|
) AS new_clients_count
|
|
FROM bookings
|
|
WHERE tenant_id = $1
|
|
`, tenantID, startsAt, endsAt).Scan(
|
|
&metrics.BookingsCount,
|
|
&metrics.CancellationsCount,
|
|
&metrics.UtilizationPercent,
|
|
&metrics.CompletedCount,
|
|
&metrics.NewClientsCount,
|
|
)
|
|
return metrics, err
|
|
}
|
|
|
|
// GetStaffMetrics mirrors GetDashboardMetrics grouped by staff_id. Class
|
|
// bookings have no staff_id and fall out naturally; revenue sums the linked
|
|
// service price for non-cancelled bookings.
|
|
func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT
|
|
b.staff_id,
|
|
COUNT(*) AS bookings_count,
|
|
COUNT(*) FILTER (WHERE b.status = 'cancelled') AS cancellations_count,
|
|
COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count,
|
|
COALESCE(
|
|
SUM(EXTRACT(EPOCH FROM (b.ends_at - b.starts_at)) / 60) FILTER (WHERE b.status <> 'cancelled'),
|
|
0
|
|
)::integer AS booked_minutes,
|
|
COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents
|
|
FROM bookings b
|
|
LEFT JOIN services s ON s.id = b.service_id
|
|
WHERE b.tenant_id = $1 AND b.staff_id IS NOT NULL AND b.starts_at >= $2 AND b.starts_at < $3
|
|
GROUP BY b.staff_id
|
|
ORDER BY bookings_count DESC
|
|
`, tenantID, startsAt, endsAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []StaffMetricsRecord
|
|
for rows.Next() {
|
|
var rec StaffMetricsRecord
|
|
if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount,
|
|
&rec.CompletedCount, &rec.BookedMinutes, &rec.RevenueCents); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, rec)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
|
|
var position int
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) + 1
|
|
FROM bookings
|
|
WHERE class_session_id = $1
|
|
AND status = 'waitlisted'
|
|
AND created_at < (SELECT created_at FROM bookings WHERE id = $2)
|
|
`, sessionID, bookingID).Scan(&position)
|
|
return position, err
|
|
}
|
|
|
|
func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE reminder_jobs
|
|
SET status = 'cancelled'
|
|
WHERE booking_id = $1 AND status = 'pending'
|
|
`, bookingID)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
DELETE FROM reminder_jobs
|
|
WHERE booking_id = $1 AND status = 'pending'
|
|
`, bookingID)
|
|
return err
|
|
}
|
|
|
|
// IsExclusionViolation reports whether err is a PostgreSQL exclusion
|
|
// constraint violation (SQLSTATE 23P01). The bookings_*_no_overlap
|
|
// constraints raise it when a concurrent request wins the race for a slot.
|
|
func IsExclusionViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23P01"
|
|
}
|