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 { 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) 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 } } if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { return err } // 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 } // Send cancellation email (async) go s.sendCancellationEmail(booking) 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) }