package bookings import ( "context" "crypto/subtle" "errors" "fmt" "strings" "time" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/notifications" ) var ( ErrInvalidReschedule = errors.New("invalid reschedule request") ErrBookingCancelled = errors.New("booking already cancelled") ErrUnauthorized = errors.New("unauthorized access") ) type CustomerService struct { repo db.Repository notifier CustomerNotifier frontendURL string } type CustomerNotifier interface { SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error } func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendURL string) *CustomerService { if notifier == nil { notifier = &customerNoopNotifier{} } if frontendURL == "" { frontendURL = "http://localhost:3000" } return &CustomerService{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")} } type customerNoopNotifier struct{} func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error { return nil } func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error { return nil } func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error { return nil } // authorize compares the caller-supplied management token against the stored // per-booking token in constant time. Bookings without a token (created before // the feature) cannot be managed through the public endpoints. func authorize(booking db.BookingRecord, token string) error { if booking.ManageToken == "" || token == "" { return ErrUnauthorized } if subtle.ConstantTimeCompare([]byte(token), []byte(booking.ManageToken)) != 1 { return ErrUnauthorized } return nil } func (s *CustomerService) managementURL(booking db.BookingRecord) string { return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) } func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) { if booking.ServiceID == nil && booking.ClassSessionID != nil { // Class bookings carry no service; the session title is the label. if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil { serviceName = session.Title } } if booking.ServiceID != nil { if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil { for _, service := range services { if service.ID == *booking.ServiceID { serviceName = service.Name break } } } } if booking.LocationID != nil { if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil { locationName = location.Name } } return serviceName, locationName } // GetBookingByReference returns booking details for customer management link func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) { booking, err := s.repo.GetBookingByReference(ctx, reference) if err != nil { return domain.CustomerBookingView{}, ErrBookingNotFound } if err := authorize(booking, token); err != nil { return domain.CustomerBookingView{}, err } tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return domain.CustomerBookingView{}, err } serviceName, locationName := s.descriptors(ctx, booking) position := 0 if booking.Status == "waitlisted" && booking.ClassSessionID != nil { position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID) } return domain.CustomerBookingView{ Reference: booking.Reference, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Service: serviceName, BusinessName: tenant.Name, TenantSlug: tenant.Slug, ServiceID: booking.ServiceID, StaffID: booking.StaffID, ClassSessionID: booking.ClassSessionID, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Location: locationName, Status: booking.Status, Notes: booking.Notes, WaitlistPosition: position, }, nil } // RescheduleBooking allows customers to reschedule their booking func (s *CustomerService) RescheduleBooking(ctx context.Context, reference string, req domain.RescheduleBookingRequest, token string) error { booking, err := s.repo.GetBookingByReference(ctx, reference) if err != nil { return ErrBookingNotFound } if err := authorize(booking, token); err != nil { return err } if booking.Status == "cancelled" { return ErrBookingCancelled } // Only live bookings may move: waitlisted entries sit in a queue tied to // their session, and completed bookings are history. if booking.Status == "waitlisted" || booking.Status == "completed" { return ErrInvalidReschedule } // Class bookings are bound to their session window; moving to another // session means cancelling and booking again. if booking.ClassSessionID != nil { return ErrInvalidReschedule } newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt) if err != nil { return ErrInvalidReschedule } newEndsAt, err := time.Parse(time.RFC3339, req.NewEndsAt) if err != nil { return ErrInvalidReschedule } if !newEndsAt.After(newStartsAt) { return ErrInvalidReschedule } if newStartsAt.Before(time.Now().UTC()) { return ErrInvalidReschedule } // Re-check resource conflicts at the requested window, excluding self. existing, err := s.repo.ListBookingsByTenantBetween(ctx, booking.TenantID, newStartsAt, newEndsAt) if err != nil { return err } for _, other := range existing { if other.ID == booking.ID || other.Status == "cancelled" || other.Status == "waitlisted" { continue } if resourcesCollide(other, booking.StaffID, booking.LocationID) { return ErrBookingConflict } } // Appointments must land inside bookable hours and off blocked days. // Class bookings never reach this point — rejected above. tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return err } if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil { return ErrInvalidReschedule } if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { if db.IsExclusionViolation(err) { return ErrBookingConflict } return err } // Re-arm the reminder for the new time; a booking moved inside the // no-reminder horizon simply gets none. if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil { if scheduledFor, ok := reminderSchedule(newStartsAt); ok { _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ TenantID: booking.TenantID, BookingID: booking.ID, Channel: "email", ScheduledFor: scheduledFor, }) } } serviceName, _ := s.descriptors(ctx, booking) _ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{ TenantID: booking.TenantID, ActorKind: "customer", ActorName: booking.CustomerName, Action: "booking.rescheduled", EntityType: "booking", EntityID: &booking.ID, Summary: booking.CustomerName + " — " + serviceName, }) // Send reschedule email (async) go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt) return nil } // CancelBooking allows customers to cancel their booking func (s *CustomerService) CancelBooking(ctx context.Context, reference string, token string) error { booking, err := s.repo.GetBookingByReference(ctx, reference) if err != nil { return ErrBookingNotFound } if err := authorize(booking, token); err != nil { return err } if booking.Status == "cancelled" { return ErrBookingCancelled } if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil { return err } _ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID) serviceName, _ := s.descriptors(ctx, booking) _ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{ TenantID: booking.TenantID, ActorKind: "customer", ActorName: booking.CustomerName, Action: "booking.cancelled", EntityType: "booking", EntityID: &booking.ID, Summary: booking.CustomerName + " — " + serviceName, }) // Send cancellation email (async) go s.sendCancellationEmail(booking) // A freed class seat promotes the earliest waitlisted booking. if booking.ClassSessionID != nil { if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID); err == nil { for _, promotedBooking := range promoted { go s.sendPromotionEmail(promotedBooking) } } } return nil } func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStartsAt, newEndsAt time.Time) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return } brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) brandColor := brand.PrimaryColor if brandColor == "" { brandColor = "#a65c3e" } serviceName, locationName := s.descriptors(ctx, booking) emailData := notifications.BookingEmailData{ TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Service: serviceName, Location: locationName, Reference: booking.Reference, StartsAt: newStartsAt, EndsAt: newEndsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, ManagementURL: s.managementURL(booking), } s.notifier.SendBookingReschedule(ctx, emailData) } func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return } brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) brandColor := brand.PrimaryColor if brandColor == "" { brandColor = "#a65c3e" } serviceName, locationName := s.descriptors(ctx, booking) emailData := notifications.BookingEmailData{ TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Service: serviceName, Location: locationName, Reference: booking.Reference, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, ManagementURL: "", } s.notifier.SendBookingCancellation(ctx, emailData) } // sendPromotionEmail confirms a booking that moved off the class waitlist. func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) if err != nil { return } brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) brandColor := brand.PrimaryColor if brandColor == "" { brandColor = "#a65c3e" } serviceName, locationName := s.descriptors(ctx, booking) s.notifier.SendBookingConfirmation(ctx, notifications.BookingEmailData{ TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Service: serviceName, Location: locationName, Reference: booking.Reference, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, 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 _ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{ TenantID: booking.TenantID, ActorKind: "customer", ActorName: booking.CustomerName, Action: "booking.moved", EntityType: "booking", EntityID: &booking.ID, Summary: booking.CustomerName + " — " + target.Title, }) go s.sendRescheduleEmail(updated, target.StartsAt, target.EndsAt) return domain.MoveBookingResponse{Status: newStatus}, nil }