package bookings import ( "context" "errors" "fmt" "sort" "time" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "github.com/jackc/pgx/v5" ) var ( ErrTenantNotFound = errors.New("tenant not found") ErrInvalidBooking = errors.New("invalid booking request") ErrBookingConflict = errors.New("booking conflict") ErrTenantMembership = errors.New("tenant membership not found") ) type Service struct { repo db.Repository } func NewService(repo db.Repository) *Service { return &Service{repo: repo} } func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) { tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PublicAvailability{}, ErrTenantNotFound } return domain.PublicAvailability{}, err } services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } windowStart := time.Now().UTC() windowEnd := windowStart.AddDate(0, 0, 7) existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd) if err != nil { return domain.PublicAvailability{}, err } classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) if err != nil { return domain.PublicAvailability{}, err } slots := make([]domain.TimeSlot, 0, 8) slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...) slots = append(slots, generateClassSlots(classSessions, existingBookings)...) sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) if len(slots) > 8 { slots = slots[:8] } return domain.PublicAvailability{ TenantSlug: tenant.Slug, Timezone: tenant.Timezone, Locale: tenant.Locale, Slots: slots, }, nil } func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) { tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.CreateBookingResponse{}, ErrTenantNotFound } return domain.CreateBookingResponse{}, err } startsAt, err := time.Parse(time.RFC3339, request.StartsAt) if err != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking) } endsAt, err := time.Parse(time.RFC3339, request.EndsAt) if err != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be RFC3339", ErrInvalidBooking) } if !endsAt.After(startsAt) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: endsAt must be after startsAt", ErrInvalidBooking) } existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt) if err != nil { return domain.CreateBookingResponse{}, err } status := "confirmed" if request.BookingMode == "class" && request.ClassSessionID != nil { classBookings := countClassBookings(existing, *request.ClassSessionID) classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16) if err != nil { return domain.CreateBookingResponse{}, err } sessionCapacity := int32(0) for _, session := range classSessions { if session.ID == *request.ClassSessionID { sessionCapacity = session.Capacity break } } if sessionCapacity > 0 && classBookings >= sessionCapacity { status = "waitlisted" } } else { for _, booking := range existing { if booking.Status == "cancelled" { continue } if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) { return domain.CreateBookingResponse{}, ErrBookingConflict } } } created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{ TenantID: tenant.ID, ServiceID: request.ServiceID, ClassSessionID: request.ClassSessionID, StaffID: request.StaffID, LocationID: request.LocationID, BookingMode: request.BookingMode, CustomerName: request.CustomerName, CustomerEmail: request.CustomerEmail, StartsAt: startsAt.UTC(), EndsAt: endsAt.UTC(), Status: status, Reference: db.Reference("BK", time.Now()), Notes: request.Notes, }) if err != nil { return domain.CreateBookingResponse{}, err } if status == "waitlisted" && request.ClassSessionID != nil { waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1 if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{ TenantID: tenant.ID, ClassSessionID: *request.ClassSessionID, CustomerName: request.CustomerName, CustomerEmail: request.CustomerEmail, Position: waitlistPosition, }); err != nil { return domain.CreateBookingResponse{}, err } } if scheduledFor, ok := reminderSchedule(startsAt); ok { if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ TenantID: tenant.ID, BookingID: created.ID, Channel: "email", ScheduledFor: scheduledFor, }); err != nil { return domain.CreateBookingResponse{}, err } } return domain.CreateBookingResponse{ BookingID: created.ID, Reference: created.Reference, Status: created.Status, }, nil } func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) { membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.DashboardSummary{}, ErrTenantMembership } return domain.DashboardSummary{}, err } now := time.Now().UTC() weekEnd := now.AddDate(0, 0, 7) metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd) if err != nil { return domain.DashboardSummary{}, err } return domain.DashboardSummary{ TenantName: membership.Tenant.Name, Locale: membership.Tenant.Locale, Timezone: membership.Tenant.Timezone, PlanCode: membership.Tenant.PlanCode, KPIs: []domain.DashboardKPI{ {Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)}, {Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)}, {Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)}, }, }, nil } func generateAppointmentSlots( tenant db.TenantRecord, services []db.ServiceRecord, rules []db.AvailabilityRuleRecord, existing []db.BookingRecord, ) []domain.TimeSlot { if len(services) == 0 || len(rules) == 0 { return nil } location, err := time.LoadLocation(tenant.Timezone) if err != nil { location = time.UTC } service := services[0] now := time.Now().In(location) var slots []domain.TimeSlot for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ { day := now.AddDate(0, 0, dayOffset) for _, rule := range rules { if int(day.Weekday()) != rule.DayOfWeek { continue } startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location) if err != nil { continue } endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location) if err != nil { continue } windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location) windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location) step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute duration := time.Duration(service.DurationMinutes) * time.Minute for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) { if slotStart.Before(now.Add(2 * time.Hour)) { continue } slotEnd := slotStart.Add(duration) if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) { continue } serviceID := service.ID slots = append(slots, domain.TimeSlot{ ServiceID: &serviceID, StaffID: rule.StaffID, StartsAt: slotStart.UTC().Format(time.RFC3339), EndsAt: slotEnd.UTC().Format(time.RFC3339), Mode: "appointment", Label: service.Name, }) if len(slots) >= 6 { break } } } } return slots } func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot { slots := make([]domain.TimeSlot, 0, len(classSessions)) for _, session := range classSessions { remaining := session.Capacity - countClassBookings(existing, session.ID) if remaining < 0 { remaining = 0 } classSessionID := session.ID locationID := session.LocationID slots = append(slots, domain.TimeSlot{ ClassSessionID: &classSessionID, LocationID: locationID, StartsAt: session.StartsAt.UTC().Format(time.RFC3339), EndsAt: session.EndsAt.UTC().Format(time.RFC3339), Mode: "class", Label: session.Title, RemainingCapacity: &remaining, }) } return slots } func collides(bookings []db.BookingRecord, staffID *string, locationID *string, startsAt time.Time, endsAt time.Time) bool { for _, booking := range bookings { if booking.Status == "cancelled" || booking.Status == "waitlisted" { continue } if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) { continue } if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) { return true } } return false } func sameResource(left *string, right *string) bool { if left == nil || right == nil { return false } return *left == *right } func countClassBookings(bookings []db.BookingRecord, classSessionID string) int32 { var total int32 for _, booking := range bookings { if booking.ClassSessionID == nil { continue } if *booking.ClassSessionID == classSessionID && booking.Status == "confirmed" { total++ } } return total } func reminderSchedule(startsAt time.Time) (time.Time, bool) { now := time.Now().UTC() switch { case startsAt.After(now.Add(25 * time.Hour)): return startsAt.Add(-24 * time.Hour), true case startsAt.After(now.Add(3 * time.Hour)): return startsAt.Add(-2 * time.Hour), true default: return time.Time{}, false } }