mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
Services and staff existed as schema + booking FK targets but had no management surface. Adds full CRUD: repository (pgx + in-memory with matching FK semantics), catalog service validation with tenant isolation, MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas, and dashboard screens for both entities. Dashboard summary now resolves staff names on bookings and computes setup completion from real configured state. Booking modal selects from the tenant's actual services; blocked days can be scoped per staff member. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
933 lines
28 KiB
Go
933 lines
28 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"
|
|
)
|
|
|
|
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")
|
|
)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}) *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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hours := make([]domain.WorkingHours, len(records))
|
|
for i, rec := range records {
|
|
hours[i] = domain.WorkingHours{
|
|
DayOfWeek: rec.DayOfWeek,
|
|
Open: rec.StartsLocal,
|
|
Close: rec.EndsLocal,
|
|
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
|
}
|
|
}
|
|
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, dayOfWeek, params)
|
|
}
|
|
|
|
// CreateBooking creates a manual booking from the dashboard. The service name
|
|
// is resolved against the tenant's services; when it matches nothing, the
|
|
// tenant's first service is used so the booking still carries a duration.
|
|
func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, req domain.DashboardBookingRequest) (domain.UpcomingBooking, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, ErrTenantMembership
|
|
}
|
|
tenant := membership.Tenant
|
|
|
|
startsAt, err := time.Parse(time.RFC3339, req.StartsAt)
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
|
|
}
|
|
|
|
customerName := strings.TrimSpace(req.CustomerName)
|
|
customerEmail := strings.TrimSpace(req.CustomerEmail)
|
|
if len(customerName) < 2 || len(customerName) > 120 {
|
|
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
|
|
}
|
|
if _, err := mail.ParseAddress(customerEmail); err != nil {
|
|
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
|
|
}
|
|
|
|
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, err
|
|
}
|
|
if len(services) == 0 {
|
|
return domain.UpcomingBooking{}, fmt.Errorf("%w: tenant has no services configured", ErrInvalidBooking)
|
|
}
|
|
|
|
selected := services[0]
|
|
requested := strings.TrimSpace(req.Service)
|
|
for _, service := range services {
|
|
if strings.EqualFold(service.Name, requested) || service.ID == requested {
|
|
selected = service
|
|
break
|
|
}
|
|
}
|
|
|
|
endsAt := startsAt.Add(time.Duration(selected.DurationMinutes) * time.Minute)
|
|
|
|
// Upsert the customer record so the booking shows up under the customers page.
|
|
customer, err := s.repo.GetCustomerByEmail(ctx, tenant.ID, customerEmail)
|
|
if err != nil {
|
|
customer, err = s.repo.CreateCustomer(ctx, db.CreateCustomerParams{
|
|
TenantID: tenant.ID,
|
|
Name: customerName,
|
|
Email: customerEmail,
|
|
Status: "active",
|
|
})
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, err
|
|
}
|
|
}
|
|
customerPhone := strings.TrimSpace(req.CustomerPhone)
|
|
if customerPhone != "" && (customer.Phone == nil || *customer.Phone != customerPhone) {
|
|
_, _ = s.repo.UpdateCustomer(ctx, customer.ID, db.UpdateCustomerParams{Phone: &customerPhone})
|
|
}
|
|
|
|
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, err
|
|
}
|
|
for _, booking := range existing {
|
|
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
|
|
continue
|
|
}
|
|
if bookings.AppointmentResourceConflict(booking, nil, nil) {
|
|
return domain.UpcomingBooking{}, ErrBookingConflict
|
|
}
|
|
}
|
|
|
|
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
|
|
TenantID: tenant.ID,
|
|
ServiceID: &selected.ID,
|
|
BookingMode: "appointment",
|
|
CustomerName: customerName,
|
|
CustomerEmail: customerEmail,
|
|
CustomerPhone: customerPhone,
|
|
StartsAt: startsAt.UTC(),
|
|
EndsAt: endsAt.UTC(),
|
|
Status: "confirmed",
|
|
Reference: db.Reference("BK", time.Now()),
|
|
ManageToken: bookings.GenerateManageToken(),
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
})
|
|
if err != nil {
|
|
return domain.UpcomingBooking{}, err
|
|
}
|
|
|
|
return domain.UpcomingBooking{
|
|
ID: created.ID,
|
|
Reference: created.Reference,
|
|
CustomerName: customerName,
|
|
CustomerEmail: customerEmail,
|
|
Service: selected.Name,
|
|
StartsAt: startsAt,
|
|
EndsAt: endsAt,
|
|
Status: created.Status,
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
}, nil
|
|
}
|