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, ''), 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.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 ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) 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, ).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 } 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" }