mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
activity_events records booking lifecycle transitions with the actor behind each change — staff (dashboard), customer (self-service), or system (auto-promotion). The notification dropdown leads with the real feed instead of derived recent-booking guesses. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
156 lines
4.8 KiB
Go
156 lines
4.8 KiB
Go
package bookings
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"bookra/apps/backend/internal/db"
|
|
)
|
|
|
|
// PromoteWaitlist confirms the earliest waitlisted bookings on a class session
|
|
// while free capacity remains. It returns the promoted records so callers can
|
|
// notify customers. A missing session is treated as nothing to promote.
|
|
func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionID string) ([]db.BookingRecord, error) {
|
|
session, err := repo.GetClassSessionByID(ctx, sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
bookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
confirmed := 0
|
|
var waitlisted []db.BookingRecord
|
|
for _, booking := range bookings {
|
|
switch booking.Status {
|
|
case "confirmed":
|
|
confirmed++
|
|
case "waitlisted":
|
|
waitlisted = append(waitlisted, booking)
|
|
}
|
|
}
|
|
if len(waitlisted) == 0 {
|
|
return nil, nil
|
|
}
|
|
sort.SliceStable(waitlisted, func(i, j int) bool {
|
|
return waitlisted[i].CreatedAt.Before(waitlisted[j].CreatedAt)
|
|
})
|
|
|
|
var promoted []db.BookingRecord
|
|
for _, booking := range waitlisted {
|
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
|
break
|
|
}
|
|
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
|
return promoted, err
|
|
}
|
|
confirmed++
|
|
promoted = append(promoted, booking)
|
|
_ = repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: tenantID,
|
|
ActorKind: "system",
|
|
Action: "booking.promoted",
|
|
EntityType: "booking",
|
|
EntityID: &booking.ID,
|
|
Summary: booking.CustomerName + " — " + session.Title,
|
|
})
|
|
}
|
|
return promoted, nil
|
|
}
|
|
|
|
// PromoteBooking manually confirms one waitlisted class booking. Unlike the
|
|
// automatic FIFO promotion it targets a specific booking; when the session is
|
|
// full it fails with ErrSessionFull unless allowOverCapacity is set — a
|
|
// deliberate business override, not a silent overbooking.
|
|
func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID string, allowOverCapacity bool) (db.BookingRecord, error) {
|
|
booking, err := repo.GetBookingByID(ctx, bookingID)
|
|
if err != nil {
|
|
return db.BookingRecord{}, err
|
|
}
|
|
if booking.TenantID != tenantID {
|
|
return db.BookingRecord{}, pgx.ErrNoRows
|
|
}
|
|
if booking.Status != "waitlisted" || booking.ClassSessionID == nil {
|
|
return db.BookingRecord{}, ErrNotWaitlisted
|
|
}
|
|
sessionID := *booking.ClassSessionID
|
|
|
|
if !allowOverCapacity {
|
|
session, err := repo.GetClassSessionByID(ctx, sessionID)
|
|
if err != nil {
|
|
return db.BookingRecord{}, err
|
|
}
|
|
sessionBookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
|
if err != nil {
|
|
return db.BookingRecord{}, err
|
|
}
|
|
confirmed := 0
|
|
for _, other := range sessionBookings {
|
|
if other.Status == "confirmed" {
|
|
confirmed++
|
|
}
|
|
}
|
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
|
return db.BookingRecord{}, ErrSessionFull
|
|
}
|
|
}
|
|
|
|
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
|
return db.BookingRecord{}, err
|
|
}
|
|
booking.Status = "confirmed"
|
|
return booking, nil
|
|
}
|
|
|
|
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
|
|
// the ledger entry, and re-arms reminder jobs — a booking promoted after its
|
|
// reminder already dispatched-as-skipped would otherwise go unreminded.
|
|
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error {
|
|
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
|
return err
|
|
}
|
|
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
|
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
|
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
|
return nil
|
|
}
|
|
|
|
// armReminderJobs schedules the email reminder — and the SMS reminder when the
|
|
// tenant opted into SMS and a phone number is on file — for a booking whose
|
|
// start still falls inside a reminder horizon. A start too close or too far
|
|
// away simply arms nothing.
|
|
func armReminderJobs(ctx context.Context, repo db.Repository, tenantID, bookingID string, startsAt time.Time, customerPhone string) error {
|
|
scheduledFor, ok := reminderSchedule(startsAt)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := repo.CreateReminderJob(ctx, db.ReminderJobParams{
|
|
TenantID: tenantID,
|
|
BookingID: bookingID,
|
|
Channel: "email",
|
|
ScheduledFor: scheduledFor,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if customerPhone != "" {
|
|
if smsSettings, err := repo.GetTenantSMSSettings(ctx, tenantID); err == nil && smsSettings.Enabled {
|
|
_ = repo.CreateReminderJob(ctx, db.ReminderJobParams{
|
|
TenantID: tenantID,
|
|
BookingID: bookingID,
|
|
Channel: "sms",
|
|
ScheduledFor: scheduledFor,
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
}
|