mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a trailing-window digest (bookings, revenue, per-service and per-staff breakdown) localized to the tenant locale; zero-activity tenants are skipped. Metrics cores extracted to tenant-scoped variants so the job needs no principal. PATCH /v1/catalog/class-sessions/:id sets a session's seat count with a confirmed-bookings floor — shrinking below it needs allowOverCapacity, matching the waitlist-promotion convention. Session rows gain an inline capacity editor with an overbook confirm state. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1902 lines
59 KiB
Go
1902 lines
59 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookra/apps/backend/internal/bookings"
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
"bookra/apps/backend/internal/notifications"
|
|
"bookra/apps/backend/internal/shared"
|
|
|
|
"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
|
|
// ============================================
|
|
|
|
// normalizeIntakeFields validates declared intake fields via the shared
|
|
// normalizer and wraps failures in ErrInvalidInput for the API layer.
|
|
func normalizeIntakeFields(fields []domain.IntakeField) (string, error) {
|
|
raw, err := shared.NormalizeIntakeFields(fields)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %s", ErrInvalidInput, err.Error())
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
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,
|
|
IntakeFields: shared.ParseIntakeFields(rec.IntakeFields),
|
|
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
|
|
}
|
|
intakeJSON, err := normalizeIntakeFields(req.IntakeFields)
|
|
if 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,
|
|
IntakeFields: intakeJSON,
|
|
})
|
|
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
|
|
}
|
|
if req.IntakeFields != nil {
|
|
intakeJSON, err := normalizeIntakeFields(*req.IntakeFields)
|
|
if err != nil {
|
|
return domain.Service{}, err
|
|
}
|
|
params.IntakeFields = &intakeJSON
|
|
}
|
|
|
|
// 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 {
|
|
if db.IsExclusionViolation(err) {
|
|
return domain.UpcomingBooking{}, ErrBookingConflict
|
|
}
|
|
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)
|
|
}
|
|
|
|
// scheduleHorizonDays is the rolling window that recurring class schedules
|
|
// materialize concrete sessions for. Sessions are written idempotently on
|
|
// read, so the horizon stays populated without a background job.
|
|
const scheduleHorizonDays = 28
|
|
|
|
// 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()
|
|
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, now, scheduleHorizonDays); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
waitlisted := 0
|
|
for _, booking := range bookings {
|
|
if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID {
|
|
continue
|
|
}
|
|
switch booking.Status {
|
|
case "waitlisted":
|
|
waitlisted++
|
|
case "cancelled":
|
|
default:
|
|
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,
|
|
WaitlistCount: waitlisted,
|
|
ScheduleID: session.ScheduleID,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListSessionRoster returns the confirmed attendees and the FIFO waitlist of a
|
|
// tenant-owned class session for the dashboard roster view.
|
|
func (s *Service) ListSessionRoster(ctx context.Context, principal domain.Principal, sessionID string) (domain.SessionRoster, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.SessionRoster{}, ErrTenantMembership
|
|
}
|
|
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.SessionRoster{}, ErrClassSessionNotFound
|
|
}
|
|
return domain.SessionRoster{}, err
|
|
}
|
|
if session.TenantID != membership.Tenant.ID {
|
|
return domain.SessionRoster{}, ErrClassSessionNotFound
|
|
}
|
|
|
|
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
|
if err != nil {
|
|
return domain.SessionRoster{}, err
|
|
}
|
|
|
|
toEntry := func(booking db.BookingRecord) domain.SessionRosterEntry {
|
|
return domain.SessionRosterEntry{
|
|
BookingID: booking.ID,
|
|
Reference: booking.Reference,
|
|
CustomerName: booking.CustomerName,
|
|
CustomerEmail: booking.CustomerEmail,
|
|
CustomerPhone: booking.CustomerPhone,
|
|
Status: booking.Status,
|
|
CreatedAt: booking.CreatedAt,
|
|
}
|
|
}
|
|
roster := domain.SessionRoster{
|
|
SessionID: session.ID,
|
|
TemplateTitle: session.Title,
|
|
StartsAt: session.StartsAt,
|
|
EndsAt: session.EndsAt,
|
|
Capacity: int(session.Capacity),
|
|
Confirmed: []domain.SessionRosterEntry{},
|
|
Waitlist: []domain.SessionRosterEntry{},
|
|
}
|
|
if session.LocationID != nil {
|
|
if location, err := s.repo.GetLocationByID(ctx, *session.LocationID); err == nil {
|
|
roster.LocationName = location.Name
|
|
}
|
|
}
|
|
for _, booking := range sessionBookings {
|
|
switch booking.Status {
|
|
case "confirmed":
|
|
roster.Confirmed = append(roster.Confirmed, toEntry(booking))
|
|
case "waitlisted":
|
|
entry := toEntry(booking)
|
|
entry.WaitlistPosition = len(roster.Waitlist) + 1
|
|
roster.Waitlist = append(roster.Waitlist, entry)
|
|
}
|
|
}
|
|
return roster, 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
|
|
}
|
|
|
|
// ListClassSchedules returns the tenant's weekly recurring rules enriched
|
|
// with template titles and location names.
|
|
func (s *Service) ListClassSchedules(ctx context.Context, principal domain.Principal) ([]domain.ClassSchedule, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return nil, ErrTenantMembership
|
|
}
|
|
tenantID := membership.Tenant.ID
|
|
|
|
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(schedules) == 0 {
|
|
return []domain.ClassSchedule{}, nil
|
|
}
|
|
templates, err := s.repo.ListClassTemplatesByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
templateTitles := make(map[string]string, len(templates))
|
|
for _, tpl := range templates {
|
|
templateTitles[tpl.ID] = tpl.Title
|
|
}
|
|
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.ClassSchedule, len(schedules))
|
|
for i, sch := range schedules {
|
|
out[i] = domain.ClassSchedule{
|
|
ID: sch.ID,
|
|
TenantID: sch.TenantID,
|
|
TemplateID: sch.TemplateID,
|
|
TemplateTitle: templateTitles[sch.TemplateID],
|
|
LocationID: sch.LocationID,
|
|
LocationName: locationNames[deref(sch.LocationID)],
|
|
DayOfWeek: sch.DayOfWeek,
|
|
StartsLocal: sch.StartsLocal,
|
|
Active: sch.Active,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CreateClassSchedule adds a weekly recurring rule for a class template, then
|
|
// immediately materializes the horizon so the new sessions appear at once.
|
|
func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Principal, req domain.CreateClassScheduleRequest) (domain.ClassSchedule, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, ErrTenantMembership
|
|
}
|
|
tenantID := membership.Tenant.ID
|
|
|
|
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
|
|
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
|
|
}
|
|
startsLocal, err := normalizeScheduleLocalTime(req.StartsLocal)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
|
|
if req.LocationID != nil {
|
|
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ClassSchedule{}, ErrLocationNotFound
|
|
}
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
if location.TenantID != tenantID {
|
|
return domain.ClassSchedule{}, ErrLocationNotFound
|
|
}
|
|
}
|
|
|
|
rec, err := s.repo.CreateClassSchedule(ctx, db.CreateClassScheduleParams{
|
|
TenantID: tenantID,
|
|
TemplateID: req.TemplateID,
|
|
LocationID: req.LocationID,
|
|
DayOfWeek: req.DayOfWeek,
|
|
StartsLocal: startsLocal,
|
|
})
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, time.Now().UTC(), scheduleHorizonDays); err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
|
|
template, _ := s.repo.GetClassTemplateByID(ctx, req.TemplateID)
|
|
out := domain.ClassSchedule{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
TemplateID: rec.TemplateID,
|
|
TemplateTitle: template.Title,
|
|
LocationID: rec.LocationID,
|
|
DayOfWeek: rec.DayOfWeek,
|
|
StartsLocal: rec.StartsLocal,
|
|
Active: rec.Active,
|
|
}
|
|
if rec.LocationID != nil {
|
|
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
|
out.LocationName = location.Name
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UpdateClassSchedule patches a recurring rule. When ApplyToFuture is set and
|
|
// the time or location changed, already-materialized future sessions move to
|
|
// the new start time (same local date) and location, their live bookings
|
|
// follow, and confirmed bookings get reminders re-armed. A weekday change only
|
|
// steers sessions materialized afterwards — existing dates are kept.
|
|
func (s *Service) UpdateClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string, req domain.UpdateClassScheduleRequest) (domain.ClassSchedule, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, ErrTenantMembership
|
|
}
|
|
tenantID := membership.Tenant.ID
|
|
|
|
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
var current *db.ClassScheduleRecord
|
|
for i := range schedules {
|
|
if schedules[i].ID == scheduleID {
|
|
current = &schedules[i]
|
|
break
|
|
}
|
|
}
|
|
if current == nil {
|
|
return domain.ClassSchedule{}, ErrClassSessionNotFound
|
|
}
|
|
|
|
params := db.UpdateClassScheduleParams{
|
|
DayOfWeek: req.DayOfWeek,
|
|
LocationID: req.LocationID,
|
|
Active: req.Active,
|
|
}
|
|
if req.DayOfWeek != nil && (*req.DayOfWeek < 0 || *req.DayOfWeek > 6) {
|
|
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
|
|
}
|
|
var startsLocal *string
|
|
if req.StartsLocal != nil {
|
|
normalized, err := normalizeScheduleLocalTime(*req.StartsLocal)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
startsLocal = &normalized
|
|
params.StartsLocal = startsLocal
|
|
}
|
|
if req.LocationID != nil && *req.LocationID != "" {
|
|
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ClassSchedule{}, ErrLocationNotFound
|
|
}
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
if location.TenantID != tenantID {
|
|
return domain.ClassSchedule{}, ErrLocationNotFound
|
|
}
|
|
}
|
|
|
|
rec, err := s.repo.UpdateClassSchedule(ctx, tenantID, scheduleID, params)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return domain.ClassSchedule{}, ErrClassSessionNotFound
|
|
}
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
|
|
if req.ApplyToFuture && (startsLocal != nil || req.LocationID != nil) {
|
|
now := time.Now().UTC()
|
|
if err := s.repo.RetimeFutureScheduleSessions(ctx, tenantID, scheduleID, startsLocal, req.LocationID, now); err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
// Re-arm reminders for confirmed bookings on the moved sessions.
|
|
sessions, err := s.repo.ListClassSessionsBySchedule(ctx, tenantID, scheduleID, now)
|
|
if err != nil {
|
|
return domain.ClassSchedule{}, err
|
|
}
|
|
for _, session := range sessions {
|
|
sessionBookings, err := s.repo.ListBookingsBySession(ctx, session.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, booking := range sessionBookings {
|
|
if booking.Status == "confirmed" {
|
|
bookings.RearmReminderJobs(ctx, s.repo, tenantID, booking)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
template, _ := s.repo.GetClassTemplateByID(ctx, rec.TemplateID)
|
|
out := domain.ClassSchedule{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
TemplateID: rec.TemplateID,
|
|
TemplateTitle: template.Title,
|
|
LocationID: rec.LocationID,
|
|
DayOfWeek: rec.DayOfWeek,
|
|
StartsLocal: rec.StartsLocal,
|
|
Active: rec.Active,
|
|
}
|
|
if rec.LocationID != nil {
|
|
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
|
out.LocationName = location.Name
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// normalizeScheduleLocalTime validates "HH:MM" and returns the canonical
|
|
// "HH:MM:SS" form the schedules table stores.
|
|
func normalizeScheduleLocalTime(raw string) (string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
parts := strings.SplitN(trimmed, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
|
|
}
|
|
hour, errH := strconv.Atoi(parts[0])
|
|
minute, errM := strconv.Atoi(parts[1])
|
|
if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
|
|
}
|
|
return fmt.Sprintf("%02d:%02d:00", hour, minute), nil
|
|
}
|
|
|
|
// DeleteClassSchedule removes a recurring rule. Sessions already materialized
|
|
// stay in place; no new ones are written.
|
|
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
if err := s.repo.DeleteClassSchedule(ctx, membership.Tenant.ID, scheduleID); err != nil {
|
|
if strings.Contains(err.Error(), "not found") {
|
|
return ErrClassSessionNotFound
|
|
}
|
|
return err
|
|
}
|
|
return 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)
|
|
}
|
|
|
|
// ErrCapacityBelowBooked rejects shrinking a session's capacity under the
|
|
// number of confirmed seats unless the caller passes allowOverCapacity.
|
|
var ErrCapacityBelowBooked = errors.New("capacity below confirmed bookings")
|
|
|
|
// UpdateClassSession patches a single session — currently only the seat
|
|
// count. Shrinking below confirmed bookings needs an explicit override so a
|
|
// typo cannot silently overbook a session.
|
|
func (s *Service) UpdateClassSession(ctx context.Context, principal domain.Principal, sessionID string, req domain.UpdateClassSessionRequest) (domain.ClassSession, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.ClassSession{}, ErrTenantMembership
|
|
}
|
|
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
|
}
|
|
return domain.ClassSession{}, err
|
|
}
|
|
if session.TenantID != membership.Tenant.ID {
|
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
|
}
|
|
if req.Capacity == nil {
|
|
return domain.ClassSession{}, fmt.Errorf("%w: capacity is required", ErrInvalidInput)
|
|
}
|
|
if *req.Capacity < 1 || *req.Capacity > 500 {
|
|
return domain.ClassSession{}, fmt.Errorf("%w: capacity must be 1-500", ErrInvalidInput)
|
|
}
|
|
|
|
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
|
if err != nil {
|
|
return domain.ClassSession{}, err
|
|
}
|
|
confirmed := 0
|
|
for _, booking := range sessionBookings {
|
|
if booking.Status == "confirmed" {
|
|
confirmed++
|
|
}
|
|
}
|
|
if *req.Capacity < confirmed && !req.AllowOverCapacity {
|
|
return domain.ClassSession{}, fmt.Errorf("%w: %d confirmed seats", ErrCapacityBelowBooked, confirmed)
|
|
}
|
|
|
|
rec, err := s.repo.UpdateClassSessionCapacity(ctx, sessionID, *req.Capacity)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ClassSession{}, ErrClassSessionNotFound
|
|
}
|
|
return domain.ClassSession{}, err
|
|
}
|
|
|
|
out := 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),
|
|
BookedCount: confirmed,
|
|
}
|
|
if rec.LocationID != nil {
|
|
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
|
|
out.LocationName = location.Name
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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)
|
|
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: membership.Tenant.ID,
|
|
ActorKind: "staff",
|
|
ActorSubject: principal.Subject,
|
|
ActorName: actorDisplayName(principal),
|
|
Action: "booking.cancelled",
|
|
EntityType: "booking",
|
|
EntityID: &booking.ID,
|
|
Summary: booking.CustomerName + " — " + s.bookingLabel(ctx, booking),
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
// PromoteBooking confirms one waitlisted class booking on behalf of the
|
|
// business. Sessions at capacity require the explicit allowOverCapacity flag;
|
|
// the promoted customer receives the standard confirmation email.
|
|
func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal, bookingID string, req domain.PromoteBookingRequest) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
promoted, err := bookings.PromoteBooking(ctx, s.repo, membership.Tenant.ID, bookingID, req.AllowOverCapacity)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrBookingNotFound
|
|
}
|
|
return err
|
|
}
|
|
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: membership.Tenant.ID,
|
|
ActorKind: "staff",
|
|
ActorSubject: principal.Subject,
|
|
ActorName: actorDisplayName(principal),
|
|
Action: "booking.promoted",
|
|
EntityType: "booking",
|
|
EntityID: &promoted.ID,
|
|
Summary: promoted.CustomerName + " — " + s.bookingLabel(ctx, promoted),
|
|
})
|
|
go s.sendBookingEmail(promoted, true)
|
|
return nil
|
|
}
|
|
|
|
// bookingLabel resolves the display label for a booking — service name for
|
|
// appointments, session title for classes.
|
|
func (s *Service) bookingLabel(ctx context.Context, booking db.BookingRecord) string {
|
|
if booking.ServiceID != nil {
|
|
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
|
|
return service.Name
|
|
}
|
|
}
|
|
if booking.ClassSessionID != nil {
|
|
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
|
|
return session.Title
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func actorDisplayName(principal domain.Principal) string {
|
|
if principal.Name != "" {
|
|
return principal.Name
|
|
}
|
|
if principal.Email != "" {
|
|
return principal.Email
|
|
}
|
|
return principal.Subject
|
|
}
|
|
|
|
// 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 locationName string
|
|
serviceName := s.bookingLabel(ctx, booking)
|
|
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
|
|
}
|