mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
347 lines
10 KiB
Go
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),
|
|
})
|
|
}
|