package catalog import ( "context" "errors" "fmt" "time" "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") ErrInvalidBooking = errors.New("invalid booking request") 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.GetTenantMembershipByUserID(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] = domain.Location{ ID: rec.ID, TenantID: rec.TenantID, Name: rec.Name, Type: "room", // Default type Capacity: 10, // Default capacity Timezone: rec.Timezone, CreatedAt: rec.CreatedAt, } } return locations, nil } func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) { membership, err := s.repo.GetTenantMembershipByUserID(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) } } } params := db.CreateLocationParams{ TenantID: membership.Tenant.ID, Name: req.Name, 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 domain.Location{ ID: rec.ID, TenantID: rec.TenantID, Name: rec.Name, Type: req.Type, Capacity: 10, Timezone: rec.Timezone, CreatedAt: rec.CreatedAt, }, nil } func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) { membership, err := s.repo.GetTenantMembershipByUserID(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 } rec, err := s.repo.UpdateLocation(ctx, locationID, params) if err != nil { return domain.Location{}, err } return domain.Location{ ID: rec.ID, TenantID: rec.TenantID, Name: rec.Name, Type: req.Type, Capacity: 10, Timezone: rec.Timezone, CreatedAt: rec.CreatedAt, }, nil } func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error { membership, err := s.repo.GetTenantMembershipByUserID(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) } // ============================================ // 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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(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.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { return ErrTenantMembership } params := db.UpdateWorkingHoursParams{} if req.Open != "" { params.StartsLocal = &req.Open } if req.Close != "" { params.EndsLocal = &req.Close } return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params) }