mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
PATCH /v1/catalog/class-schedules/:id patches a recurring rule and, with applyToFuture, retimes materialized future sessions to the new local start time on their existing dates — moving confirmed and waitlisted bookings with them and re-arming reminders. Zones-page schedule rows gain an inline editor (CS/EN). Also drops the vestigial waitlist_entries table (migration 00020): positions derive from bookings.created_at, so the ledger was written but never read. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
569 lines
18 KiB
Go
569 lines
18 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"
|
|
"bookra/apps/backend/internal/shared"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// intakeLabels returns the booking's service-declared intake field labels,
|
|
// keyed by answer key; nil for class bookings or services without intake.
|
|
func (s *CustomerService) intakeLabels(ctx context.Context, booking db.BookingRecord) map[string]string {
|
|
if booking.ServiceID == nil {
|
|
return nil
|
|
}
|
|
service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return shared.IntakeLabels(service.IntakeFields)
|
|
}
|
|
|
|
// 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,
|
|
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
|
IntakeLabels: s.intakeLabels(ctx, booking),
|
|
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 := 0
|
|
for _, other := range targetBookings {
|
|
if other.Status == "confirmed" {
|
|
confirmed++
|
|
}
|
|
}
|
|
|
|
wasConfirmed := booking.Status == "confirmed"
|
|
newStatus := "confirmed"
|
|
if target.Capacity > 0 && confirmed >= int(target.Capacity) {
|
|
newStatus = "waitlisted"
|
|
}
|
|
|
|
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); 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 newStatus == "confirmed" {
|
|
booking.StartsAt, booking.EndsAt = target.StartsAt, target.EndsAt
|
|
RearmReminderJobs(ctx, s.repo, booking.TenantID, booking)
|
|
}
|
|
|
|
// 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
|
|
}
|