Files
Bookra/apps/backend/internal/catalog/service.go
T
Tomas DvorakandDevin d86cba61e3 feat(activity): per-tenant audit feed in notification dropdown
activity_events records booking lifecycle transitions with the actor
behind each change — staff (dashboard), customer (self-service), or
system (auto-promotion). The notification dropdown leads with the real
feed instead of derived recent-booking guesses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 16:06:13 +02:00

1542 lines
47 KiB
Go

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