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") 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) } // ============================================ // 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 }