mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Tenants paying for the SMS add-on still got email-only reminders — the channel column existed but nothing armed or dispatched sms jobs. Booking creation now adds a second job when SMS is enabled and the customer left a phone; dispatch routes sms jobs through an injected sender adapter wired to the existing sms.Service, so monthly limits, usage metering, and Stripe billing apply unchanged. Lifecycle handling from the previous milestone clears and re-arms both channels together. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
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) 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
|
|
}
|