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, err := validateClassTemplateInput(req) if err != nil { return domain.ClassTemplate{}, err } 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) } } func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.ClassTemplate{}, ErrTenantMembership } if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil { return domain.ClassTemplate{}, err } title, err := validateClassTemplateInput(req) if err != nil { return domain.ClassTemplate{}, err } rec, err := s.repo.UpdateClassTemplate(ctx, templateID, 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 validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) { title := strings.TrimSpace(req.Title) if len(title) < 1 || len(title) > 120 { return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput) } if req.DurationMinutes < 1 || req.DurationMinutes > 1440 { return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput) } if req.Capacity < 1 || req.Capacity > 500 { return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput) } return title, nil }