Files
Bookra/apps/backend/internal/bookings/customer_service.go
T
Tomas DvorakandDevin cdad7875e1 fix(reminders): lifecycle-aware jobs and dispatch skip guard
The reminder pipeline fired regardless of what happened to the booking
after creation: cancelled customers got reminded about bookings that
weren't happening, reschedules kept the old reminder time, and
waitlisted bookings armed jobs too. Pending jobs are now cancelled on
both cancel paths, deleted and re-armed on reschedule, and dispatch
joins bookings.status to skip anything not confirmed. Reschedule also
gained the same availability-window and blocked-day validation as
booking creation, closing a 3am-reschedule hole via the manage URL.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 12:47:07 +02:00

347 lines
10 KiB
Go

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 {
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)
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
}, 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
}
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 keep their session-defined window.
if booking.ClassSessionID == nil {
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 {
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,
})
}
}
// 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)
// 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),
})
}