mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(bookings): session-to-session move for class bookings
Class customers can move a booking to another future session of the same template from the manage page. Full target sessions place the booking on the waitlist; freeing a confirmed seat drains the old session's queue via the existing promotion path. Reminders re-arm for the new time. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6c3b2bf6bf
commit
e5cb9bd543
10 files changed
+927
-10
No files matched your search
@@ -373,3 +373,168 @@ func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) {
|
||||
ManagementURL: s.managementURL(booking),
|
||||
})
|
||||
}
|
||||
|
||||
// classBookingForMove returns the booking and its current session when the
|
||||
// record is a live class booking, or an error explaining why it cannot move.
|
||||
func (s *CustomerService) classBookingForMove(ctx context.Context, reference string, token string) (db.BookingRecord, db.ClassSessionRecord, error) {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
if err != nil {
|
||||
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrBookingNotFound
|
||||
}
|
||||
if err := authorize(booking, token); err != nil {
|
||||
return db.BookingRecord{}, db.ClassSessionRecord{}, err
|
||||
}
|
||||
if booking.Status == "cancelled" {
|
||||
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrBookingCancelled
|
||||
}
|
||||
if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") {
|
||||
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrInvalidReschedule
|
||||
}
|
||||
session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID)
|
||||
if err != nil {
|
||||
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrInvalidReschedule
|
||||
}
|
||||
return booking, session, nil
|
||||
}
|
||||
|
||||
// ListSessionMoveTargets returns upcoming sessions of the same class template
|
||||
// the customer could move to, with live capacity info for the picker.
|
||||
func (s *CustomerService) ListSessionMoveTargets(ctx context.Context, reference string, token string) ([]domain.SessionMoveTarget, error) {
|
||||
booking, session, err := s.classBookingForMove(ctx, reference, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
sessions, err := s.repo.ListClassSessionsByTenant(ctx, booking.TenantID, now, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations, _ := s.repo.ListLocationsByTenant(ctx, booking.TenantID)
|
||||
locationNames := map[string]string{}
|
||||
for _, location := range locations {
|
||||
locationNames[location.ID] = location.Name
|
||||
}
|
||||
|
||||
var targets []domain.SessionMoveTarget
|
||||
for _, candidate := range sessions {
|
||||
if candidate.ID == session.ID || candidate.TemplateID != session.TemplateID {
|
||||
continue
|
||||
}
|
||||
if !candidate.StartsAt.After(now) {
|
||||
continue
|
||||
}
|
||||
bookings, err := s.repo.ListBookingsBySession(ctx, candidate.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmed, waitlisted := 0, 0
|
||||
for _, other := range bookings {
|
||||
switch other.Status {
|
||||
case "confirmed":
|
||||
confirmed++
|
||||
case "waitlisted":
|
||||
waitlisted++
|
||||
}
|
||||
}
|
||||
location := ""
|
||||
if candidate.LocationID != nil {
|
||||
location = locationNames[*candidate.LocationID]
|
||||
}
|
||||
targets = append(targets, domain.SessionMoveTarget{
|
||||
SessionID: candidate.ID,
|
||||
Title: candidate.Title,
|
||||
StartsAt: candidate.StartsAt,
|
||||
EndsAt: candidate.EndsAt,
|
||||
Location: location,
|
||||
Capacity: candidate.Capacity,
|
||||
ConfirmedCount: confirmed,
|
||||
WaitlistCount: waitlisted,
|
||||
Remaining: int(candidate.Capacity) - confirmed,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// MoveBookingToSession retargets a class booking to another session of the
|
||||
// same template. The freed spot (confirmed or queue position) is released on
|
||||
// the old session — a confirmed move also drains its waitlist — and the
|
||||
// booking is re-seated or re-queued on the target.
|
||||
func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference string, token string, targetSessionID string) (domain.MoveBookingResponse, error) {
|
||||
booking, currentSession, err := s.classBookingForMove(ctx, reference, token)
|
||||
if err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
|
||||
target, err := s.repo.GetClassSessionByID(ctx, targetSessionID)
|
||||
if err != nil {
|
||||
return domain.MoveBookingResponse{}, ErrInvalidReschedule
|
||||
}
|
||||
if target.TenantID != booking.TenantID || target.TemplateID != currentSession.TemplateID || target.ID == currentSession.ID {
|
||||
return domain.MoveBookingResponse{}, ErrInvalidReschedule
|
||||
}
|
||||
if !target.StartsAt.After(time.Now().UTC()) {
|
||||
return domain.MoveBookingResponse{}, ErrInvalidReschedule
|
||||
}
|
||||
|
||||
targetBookings, err := s.repo.ListBookingsBySession(ctx, target.ID)
|
||||
if err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
confirmed, waitlisted := 0, 0
|
||||
for _, other := range targetBookings {
|
||||
switch other.Status {
|
||||
case "confirmed":
|
||||
confirmed++
|
||||
case "waitlisted":
|
||||
waitlisted++
|
||||
}
|
||||
}
|
||||
|
||||
wasConfirmed := booking.Status == "confirmed"
|
||||
newStatus := "confirmed"
|
||||
if target.Capacity > 0 && confirmed >= int(target.Capacity) {
|
||||
newStatus = "waitlisted"
|
||||
}
|
||||
|
||||
// Release the old spot first so the seat/queue position is free before
|
||||
// the retarget lands.
|
||||
if booking.Status == "waitlisted" {
|
||||
_ = s.repo.ResolveWaitlistEntry(ctx, booking.TenantID, currentSession.ID, booking.CustomerEmail)
|
||||
}
|
||||
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
if newStatus == "waitlisted" {
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: booking.TenantID,
|
||||
ClassSessionID: target.ID,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Position: waitlisted + 1,
|
||||
}); err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-arm reminders for the new window (confirmed only; a queue spot
|
||||
// re-arms on promotion through the shared confirm path).
|
||||
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" {
|
||||
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone)
|
||||
}
|
||||
|
||||
// A confirmed move frees a seat on the old session — drain its queue.
|
||||
if wasConfirmed {
|
||||
if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, currentSession.ID); err == nil {
|
||||
for _, promotedBooking := range promoted {
|
||||
go s.sendPromotionEmail(promotedBooking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated := booking
|
||||
updated.ClassSessionID = &target.ID
|
||||
go s.sendRescheduleEmail(updated, target.StartsAt, target.EndsAt)
|
||||
|
||||
return domain.MoveBookingResponse{Status: newStatus}, nil
|
||||
}
|
||||
Reference in new issue
Block a user