Files
Bookra/apps/backend/internal/catalog/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

1375 lines
42 KiB
Go

package catalog
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"github.com/jackc/pgx/v5"
)
var (
ErrLocationNotFound = errors.New("location not found")
ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrServiceNotFound = errors.New("service not found")
ErrStaffNotFound = errors.New("staff member not found")
ErrInvalidInput = errors.New("invalid request")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
ErrClassTemplateNotFound = errors.New("class template not found")
ErrClassSessionNotFound = errors.New("class session not found")
)
type Service struct {
repo db.Repository
billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}
notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
}
func NewService(repo db.Repository, billingService interface {
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
}, notificationService interface {
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}) *Service {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
// ============================================
// LOCATION / ZONE MANAGEMENT
// ============================================
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
locations := make([]domain.Location, len(records))
for i, rec := range records {
locations[i] = locationToDomain(rec)
}
return locations, nil
}
func locationToDomain(rec db.LocationRecord) domain.Location {
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: rec.Type,
Capacity: rec.Capacity,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
}
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
// Check plan entitlements for location limit
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
// Count existing locations
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
if err == nil && len(locations) >= entitlements.MaxLocations {
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
}
}
}
locationType := strings.TrimSpace(req.Type)
if locationType == "" {
locationType = "room"
}
capacity := req.Capacity
if capacity <= 0 {
capacity = 1
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Type: locationType,
Capacity: capacity,
Timezone: membership.Tenant.Timezone,
}
rec, err := s.repo.CreateLocation(ctx, params)
if err != nil {
return domain.Location{}, err
}
// Send usage warning if at 80%+ of limit
if s.notificationService != nil && s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxLocations > 0 {
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
if usagePercent >= 80 {
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
}
}
}
return locationToDomain(rec), nil
}
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
// Verify location belongs to tenant
loc, err := s.repo.GetLocationByID(ctx, locationID)
if err != nil {
return domain.Location{}, ErrLocationNotFound
}
if loc.TenantID != membership.Tenant.ID {
return domain.Location{}, ErrLocationNotFound
}
params := db.UpdateLocationParams{}
if req.Name != "" {
params.Name = &req.Name
}
if req.Type != "" {
params.Type = &req.Type
}
if req.Capacity > 0 {
params.Capacity = &req.Capacity
}
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
if err != nil {
return domain.Location{}, err
}
return locationToDomain(rec), nil
}
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
loc, err := s.repo.GetLocationByID(ctx, locationID)
if err != nil {
return ErrLocationNotFound
}
if loc.TenantID != membership.Tenant.ID {
return ErrLocationNotFound
}
return s.repo.DeleteLocation(ctx, locationID)
}
// ============================================
// SERVICE CATALOG MANAGEMENT
// ============================================
func serviceToDomain(rec db.ServiceRecord) domain.Service {
return domain.Service{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
DurationMinutes: rec.DurationMinutes,
BufferBeforeMinutes: rec.BufferBeforeMinutes,
BufferAfterMinutes: rec.BufferAfterMinutes,
PriceCents: rec.PriceCents,
CreatedAt: rec.CreatedAt,
}
}
func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput)
}
if duration <= 0 || duration > 24*60 {
return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if bufferBefore < 0 || bufferBefore > 480 {
return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput)
}
if bufferAfter < 0 || bufferAfter > 480 {
return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput)
}
if priceCents < 0 {
return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput)
}
return nil
}
func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
services := make([]domain.Service, len(records))
for i, rec := range records {
services[i] = serviceToDomain(rec)
}
return services, nil
}
func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
name := strings.TrimSpace(req.Name)
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
TenantID: membership.Tenant.ID,
Name: name,
DurationMinutes: req.DurationMinutes,
BufferBeforeMinutes: req.BufferBeforeMinutes,
BufferAfterMinutes: req.BufferAfterMinutes,
PriceCents: req.PriceCents,
})
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.Service{}, ErrServiceNotFound
}
params := db.UpdateServiceParams{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
params.Name = &name
}
if req.DurationMinutes != nil {
params.DurationMinutes = req.DurationMinutes
}
if req.BufferBeforeMinutes != nil {
params.BufferBeforeMinutes = req.BufferBeforeMinutes
}
if req.BufferAfterMinutes != nil {
params.BufferAfterMinutes = req.BufferAfterMinutes
}
if req.PriceCents != nil {
params.PriceCents = req.PriceCents
}
// Validate against the merged result so partial updates cannot produce an
// invalid record (e.g. setting duration to 0 while keeping the name).
merged := existing
if params.Name != nil {
merged.Name = *params.Name
}
if params.DurationMinutes != nil {
merged.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
merged.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
merged.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
merged.PriceCents = *params.PriceCents
}
if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.UpdateService(ctx, serviceID, params)
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrServiceNotFound
}
return s.repo.DeleteService(ctx, serviceID)
}
// ============================================
// STAFF MANAGEMENT
// ============================================
func staffToDomain(rec db.StaffRecord) domain.StaffMember {
return domain.StaffMember{
ID: rec.ID,
TenantID: rec.TenantID,
LocationID: rec.LocationID,
DisplayName: rec.DisplayName,
Active: rec.Active,
CreatedAt: rec.CreatedAt,
}
}
func validateStaffName(name string) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput)
}
return nil
}
// validateStaffLocation returns the normalized location reference: nil when the
// input is empty, the id when it belongs to the tenant, or an error otherwise.
func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) {
if locationID == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*locationID)
if trimmed == "" {
return nil, nil
}
loc, err := s.repo.GetLocationByID(ctx, trimmed)
if err != nil || loc.TenantID != tenantID {
return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput)
}
return &trimmed, nil
}
func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
staff := make([]domain.StaffMember, len(records))
for i, rec := range records {
staff[i] = staffToDomain(rec)
}
return staff, nil
}
func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxStaff >= 0 {
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err == nil && len(staff) >= entitlements.MaxStaff {
return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff)
}
}
}
name := strings.TrimSpace(req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
active := true
if req.Active != nil {
active = *req.Active
}
rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{
TenantID: membership.Tenant.ID,
LocationID: locationID,
DisplayName: name,
Active: active,
})
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.StaffMember{}, ErrStaffNotFound
}
params := db.UpdateStaffParams{Active: req.Active}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
params.DisplayName = &name
}
if req.LocationID != nil {
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
// Keep the tri-state: an explicit empty string must clear the value.
if locationID == nil {
empty := ""
params.LocationID = &empty
} else {
params.LocationID = locationID
}
}
rec, err := s.repo.UpdateStaff(ctx, staffID, params)
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrStaffNotFound
}
return s.repo.DeleteStaff(ctx, staffID)
}
// ============================================
// BLOCKED DAYS MANAGEMENT
// ============================================
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
if err != nil {
return nil, err
}
blockedDays := make([]domain.BlockedDay, len(records))
for i, rec := range records {
blockedDays[i] = domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}
}
return blockedDays, nil
}
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
date, err := time.Parse(time.RFC3339, req.Date)
if err != nil {
return domain.BlockedDay{}, ErrInvalidBooking
}
params := db.CreateBlockedDayParams{
TenantID: membership.Tenant.ID,
StaffID: req.StaffID,
StartsAt: date,
EndsAt: date.Add(24 * time.Hour),
Kind: req.Type,
Reason: req.Reason,
}
rec, err := s.repo.CreateBlockedDay(ctx, params)
if err != nil {
return domain.BlockedDay{}, err
}
return domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
// Verify blocked day belongs to tenant
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
if err != nil {
return domain.BlockedDay{}, err
}
found := false
for _, b := range bd {
if b.ID == blockedDayID {
found = true
break
}
}
if !found {
return domain.BlockedDay{}, ErrBlockedDayNotFound
}
params := db.UpdateBlockedDayParams{}
if req.Reason != "" {
params.Reason = &req.Reason
}
if req.Type != "" {
params.Kind = &req.Type
}
rec, err := s.repo.UpdateBlockedDay(ctx, blockedDayID, params)
if err != nil {
return domain.BlockedDay{}, err
}
return domain.BlockedDay{
ID: rec.ID,
TenantID: rec.TenantID,
Date: rec.StartsAt,
Reason: rec.Reason,
Type: rec.Kind,
StaffID: rec.StaffID,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
// Verify blocked day belongs to tenant
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
if err != nil {
return err
}
found := false
for _, b := range bd {
if b.ID == blockedDayID {
found = true
break
}
}
if !found {
return ErrBlockedDayNotFound
}
return s.repo.DeleteBlockedDay(ctx, blockedDayID)
}
// ============================================
// CUSTOMER MANAGEMENT
// ============================================
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListCustomersByTenant(ctx, membership.Tenant.ID, limit, offset)
if err != nil {
return nil, err
}
customers := make([]domain.Customer, len(records))
for i, rec := range records {
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
customers[i] = domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
BookingsCount: bookingsCount,
LastBookingAt: lastBooking,
CreatedAt: rec.CreatedAt,
Notes: "",
}
if rec.Notes != nil {
customers[i].Notes = *rec.Notes
}
}
return customers, nil
}
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
status := req.Status
if status == "" {
status = "active"
}
params := db.CreateCustomerParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Status: status,
Notes: nil,
}
rec, err := s.repo.CreateCustomer(ctx, params)
if err != nil {
return domain.Customer{}, err
}
return domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
CreatedAt: rec.CreatedAt,
Notes: "",
}, nil
}
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
// Verify customer belongs to tenant
cust, err := s.repo.GetCustomerByID(ctx, customerID)
if err != nil {
return domain.Customer{}, ErrCustomerNotFound
}
if cust.TenantID != membership.Tenant.ID {
return domain.Customer{}, ErrCustomerNotFound
}
params := db.UpdateCustomerParams{}
if req.Name != "" {
params.Name = &req.Name
}
if req.Email != "" {
params.Email = &req.Email
}
if req.Phone != nil {
params.Phone = req.Phone
}
if req.Status != "" {
params.Status = &req.Status
}
if req.Notes != "" {
params.Notes = &req.Notes
}
rec, err := s.repo.UpdateCustomer(ctx, customerID, params)
if err != nil {
return domain.Customer{}, err
}
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
return domain.Customer{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Email: rec.Email,
Phone: rec.Phone,
Status: rec.Status,
BookingsCount: bookingsCount,
LastBookingAt: lastBooking,
CreatedAt: rec.CreatedAt,
Notes: "",
}, nil
}
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
cust, err := s.repo.GetCustomerByID(ctx, customerID)
if err != nil {
return ErrCustomerNotFound
}
if cust.TenantID != membership.Tenant.ID {
return ErrCustomerNotFound
}
return s.repo.DeleteCustomer(ctx, customerID)
}
// ============================================
// WORKING HOURS MANAGEMENT
// ============================================
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, nil)
if err != nil {
return nil, err
}
hours := make([]domain.WorkingHours, len(records))
for i, rec := range records {
open := hhmm(rec.StartsLocal)
close_ := hhmm(rec.EndsLocal)
hours[i] = domain.WorkingHours{
DayOfWeek: rec.DayOfWeek,
Open: open,
Close: close_,
IsOpen: open != "" && close_ != "",
}
}
return hours, nil
}
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" {
if _, err := time.Parse("15:04", req.Open); err != nil {
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
}
params.StartsLocal = &req.Open
}
if req.Close != "" {
if _, err := time.Parse("15:04", req.Close); err != nil {
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
}
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, nil, dayOfWeek, params)
}
// ListStaffWorkingHours returns the per-person schedule for a tenant-owned
// staff member.
func (s *Service) ListStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
return nil, err
}
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, &staffID)
if err != nil {
return nil, err
}
hours := make([]domain.WorkingHours, len(records))
for i, rec := range records {
open := hhmm(rec.StartsLocal)
close_ := hhmm(rec.EndsLocal)
hours[i] = domain.WorkingHours{
DayOfWeek: rec.DayOfWeek,
Open: open,
Close: close_,
IsOpen: open != "" && close_ != "",
}
}
return hours, nil
}
// UpdateStaffWorkingHours replaces a staff member's availability rule for one
// weekday. The rule directly drives public slot generation for that person.
func (s *Service) UpdateStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil {
return err
}
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" {
if _, err := time.Parse("15:04", req.Open); err != nil {
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
}
params.StartsLocal = &req.Open
}
if req.Close != "" {
if _, err := time.Parse("15:04", req.Close); err != nil {
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
}
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, &staffID, dayOfWeek, params)
}
// requireTenantStaff verifies the staff record exists and belongs to the
// tenant, returning a tenant-safe not-found error otherwise.
func (s *Service) requireTenantStaff(ctx context.Context, tenantID, staffID string) error {
staff, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrStaffNotFound
}
return err
}
if staff.TenantID != tenantID {
return ErrStaffNotFound
}
return nil
}
// CreateBooking creates a manual booking from the dashboard. The service name
// is resolved against the tenant's services; when it matches nothing, the
// tenant's first service is used so the booking still carries a duration.
func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, req domain.DashboardBookingRequest) (domain.UpcomingBooking, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.UpcomingBooking{}, ErrTenantMembership
}
tenant := membership.Tenant
startsAt, err := time.Parse(time.RFC3339, req.StartsAt)
if err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
}
customerName := strings.TrimSpace(req.CustomerName)
customerEmail := strings.TrimSpace(req.CustomerEmail)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
}
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return domain.UpcomingBooking{}, err
}
if len(services) == 0 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: tenant has no services configured", ErrInvalidBooking)
}
selected := services[0]
requested := strings.TrimSpace(req.Service)
for _, service := range services {
if strings.EqualFold(service.Name, requested) || service.ID == requested {
selected = service
break
}
}
endsAt := startsAt.Add(time.Duration(selected.DurationMinutes) * time.Minute)
// Upsert the customer record so the booking shows up under the customers page.
customer, err := s.repo.GetCustomerByEmail(ctx, tenant.ID, customerEmail)
if err != nil {
customer, err = s.repo.CreateCustomer(ctx, db.CreateCustomerParams{
TenantID: tenant.ID,
Name: customerName,
Email: customerEmail,
Status: "active",
})
if err != nil {
return domain.UpcomingBooking{}, err
}
}
customerPhone := strings.TrimSpace(req.CustomerPhone)
if customerPhone != "" && (customer.Phone == nil || *customer.Phone != customerPhone) {
_, _ = s.repo.UpdateCustomer(ctx, customer.ID, db.UpdateCustomerParams{Phone: &customerPhone})
}
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
if err != nil {
return domain.UpcomingBooking{}, err
}
for _, booking := range existing {
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
continue
}
if bookings.AppointmentResourceConflict(booking, nil, nil) {
return domain.UpcomingBooking{}, ErrBookingConflict
}
}
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenant.ID,
ServiceID: &selected.ID,
BookingMode: "appointment",
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: "confirmed",
Reference: db.Reference("BK", time.Now()),
ManageToken: bookings.GenerateManageToken(),
Notes: strings.TrimSpace(req.Notes),
})
if err != nil {
return domain.UpcomingBooking{}, err
}
return domain.UpcomingBooking{
ID: created.ID,
Reference: created.Reference,
CustomerName: customerName,
CustomerEmail: customerEmail,
Service: selected.Name,
StartsAt: startsAt,
EndsAt: endsAt,
Status: created.Status,
Notes: strings.TrimSpace(req.Notes),
}, nil
}
// ============================================
// CLASS TEMPLATE & SESSION MANAGEMENT
// ============================================
func (s *Service) ListClassTemplates(ctx context.Context, principal domain.Principal) ([]domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListClassTemplatesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
templates := make([]domain.ClassTemplate, len(records))
for i, rec := range records {
templates[i] = domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}
}
return templates, nil
}
func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Principal, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassTemplate{}, ErrTenantMembership
}
title := strings.TrimSpace(req.Title)
if len(title) < 1 || len(title) > 120 {
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
}
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if req.Capacity < 1 || req.Capacity > 500 {
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
TenantID: membership.Tenant.ID,
Title: title,
DurationMinutes: req.DurationMinutes,
Capacity: req.Capacity,
})
if err != nil {
return domain.ClassTemplate{}, err
}
return domain.ClassTemplate{
ID: rec.ID,
TenantID: rec.TenantID,
Title: rec.Title,
DurationMinutes: rec.DurationMinutes,
Capacity: rec.Capacity,
CreatedAt: rec.CreatedAt,
}, nil
}
func (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Principal, templateID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
return err
}
return s.repo.DeleteClassTemplate(ctx, templateID)
}
// ListClassSessions returns upcoming sessions enriched with location names and
// live booked counts for the management UI.
func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100)
if err != nil {
return nil, err
}
if len(sessions) == 0 {
return []domain.ClassSession{}, nil
}
var windowEnd time.Time
for _, session := range sessions {
if session.EndsAt.After(windowEnd) {
windowEnd = session.EndsAt
}
}
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, now, windowEnd)
if err != nil {
return nil, err
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
locationNames := make(map[string]string, len(locations))
for _, loc := range locations {
locationNames[loc.ID] = loc.Name
}
out := make([]domain.ClassSession, len(sessions))
for i, session := range sessions {
booked := 0
for _, booking := range bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID &&
booking.Status != "cancelled" && booking.Status != "waitlisted" {
booked++
}
}
out[i] = domain.ClassSession{
ID: session.ID,
TenantID: session.TenantID,
TemplateID: session.TemplateID,
TemplateTitle: session.Title,
LocationID: session.LocationID,
LocationName: locationNames[deref(session.LocationID)],
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Capacity: int(session.Capacity),
BookedCount: booked,
}
}
return out, nil
}
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSession{}, ErrTenantMembership
}
tenantID := membership.Tenant.ID
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
return domain.ClassSession{}, err
}
template, err := s.repo.GetClassTemplateByID(ctx, req.TemplateID)
if err != nil {
return domain.ClassSession{}, err
}
startsAt, err := time.Parse(time.RFC3339, strings.TrimSpace(req.StartsAt))
if err != nil {
return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidInput)
}
if !startsAt.After(time.Now()) {
return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidInput)
}
if req.LocationID != nil {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSession{}, ErrLocationNotFound
}
return domain.ClassSession{}, err
}
if location.TenantID != tenantID {
return domain.ClassSession{}, ErrLocationNotFound
}
}
capacity := template.Capacity
if req.Capacity != nil {
if *req.Capacity < 1 || *req.Capacity > 500 {
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
capacity = *req.Capacity
}
endsAt := startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute)
rec, err := s.repo.CreateClassSession(ctx, db.CreateClassSessionParams{
TenantID: tenantID,
TemplateID: req.TemplateID,
LocationID: req.LocationID,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Capacity: int32(capacity),
})
if err != nil {
return domain.ClassSession{}, err
}
return domain.ClassSession{
ID: rec.ID,
TenantID: rec.TenantID,
TemplateID: rec.TemplateID,
TemplateTitle: rec.Title,
LocationID: rec.LocationID,
StartsAt: rec.StartsAt,
EndsAt: rec.EndsAt,
Capacity: int(rec.Capacity),
}, nil
}
func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrClassSessionNotFound
}
return err
}
if session.TenantID != membership.Tenant.ID {
return ErrClassSessionNotFound
}
return s.repo.DeleteClassSession(ctx, sessionID)
}
func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error {
template, err := s.repo.GetClassTemplateByID(ctx, templateID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrClassTemplateNotFound
}
return err
}
if template.TenantID != tenantID {
return ErrClassTemplateNotFound
}
return nil
}
func deref(value *string) string {
if value == nil {
return ""
}
return *value
}
// hhmm trims a local time value to HH:MM; PostgreSQL returns time columns as
// HH:MM:SS while the API contract uses HH:MM.
func hhmm(value string) string {
if len(value) >= 5 {
return value[:5]
}
return value
}
// ============================================
// BOOKING LIFECYCLE
// ============================================
// CancelBooking cancels a tenant-owned booking from the dashboard. Cancelling a
// class booking frees the seat and promotes the earliest waitlisted bookings.
func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, bookingID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
booking, err := s.repo.GetBookingByID(ctx, bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrBookingNotFound
}
return err
}
if booking.TenantID != membership.Tenant.ID {
return ErrBookingNotFound
}
if booking.Status == "cancelled" {
return fmt.Errorf("%w: booking already cancelled", ErrInvalidInput)
}
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
go s.sendBookingEmail(booking, false)
if booking.ClassSessionID != nil {
promoted, err := bookings.PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID)
if err != nil {
return nil
}
for _, promotedBooking := range promoted {
go s.sendBookingEmail(promotedBooking, true)
}
}
return nil
}
// sendBookingEmail builds the shared payload and fires a confirmation
// (promoted=true) or cancellation notification. Failures are logged-and-done:
// email delivery must not fail the cancellation itself.
func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
if s.notificationService == nil {
return
}
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"
}
var serviceName, locationName string
if booking.ServiceID != nil {
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
serviceName = service.Name
}
}
if booking.ClassSessionID != nil {
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
serviceName = session.Title
}
}
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
}
}
data := 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,
}
if promoted {
s.notificationService.SendBookingConfirmation(ctx, data)
} else {
s.notificationService.SendBookingCancellation(ctx, data)
}
}