package bookings import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "net/mail" "sort" "strconv" "strings" "time" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/notifications" "bookra/apps/backend/internal/shared" "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") ErrBookingNotFound = errors.New("booking not found") ErrNotWaitlisted = errors.New("booking is not on a waitlist") ErrSessionFull = errors.New("class session is at capacity") ) type Service struct { repo db.Repository notifier Notifier frontendURL string } type Notifier interface { SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error } func NewService(repo db.Repository, notifier Notifier, frontendURL string) *Service { if notifier == nil { notifier = &noopNotifier{} } if frontendURL == "" { frontendURL = "http://localhost:3000" } return &Service{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")} } type noopNotifier struct{} func (n *noopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error { return nil } func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error { return nil } type AvailabilityFilter struct { ServiceID string StaffID string Date string } func (s *Service) Availability(ctx context.Context, tenantSlug string, filter AvailabilityFilter) (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 } location, err := time.LoadLocation(tenant.Timezone) if err != nil { location = time.UTC } services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } if filter.ServiceID != "" { found := false for _, service := range services { if service.ID == filter.ServiceID { services = []db.ServiceRecord{service} found = true break } } if !found { return domain.PublicAvailability{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking) } } staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } staffNames := make(map[string]string, len(staffRecords)) for _, staff := range staffRecords { staffNames[staff.ID] = staff.DisplayName } var staffFilter *string if filter.StaffID != "" { for _, staff := range staffRecords { if staff.ID == filter.StaffID && staff.Active { staffID := staff.ID staffFilter = &staffID break } } if staffFilter == nil { return domain.PublicAvailability{}, fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) } } rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } now := time.Now() windowStart := now.UTC() windowEnd := windowStart.AddDate(0, 0, 7) var days []time.Time if filter.Date != "" { day, err := time.ParseInLocation("2006-01-02", filter.Date, location) if err != nil { return domain.PublicAvailability{}, fmt.Errorf("%w: date must be YYYY-MM-DD", ErrInvalidBooking) } days = []time.Time{day} windowStart = day.UTC() windowEnd = day.AddDate(0, 0, 1).UTC() } existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd) if err != nil { return domain.PublicAvailability{}, err } blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, windowStart, windowEnd) if err != nil { return domain.PublicAvailability{}, err } if days != nil { slots := appointmentSlotsForDays(tenant, location, now, days, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 0) sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) return domain.PublicAvailability{ TenantSlug: tenant.Slug, Timezone: tenant.Timezone, Locale: tenant.Locale, Slots: slots, }, nil } // Recurring schedules write their concrete sessions here so the public // booking page sees them without a background job. Idempotent. if err := s.repo.MaterializeScheduleSessions(ctx, tenant.ID, now, 28); err != nil { return domain.PublicAvailability{}, err } classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) if err != nil { return domain.PublicAvailability{}, err } previewDays := make([]time.Time, 0, 7) for offset := 0; offset < 7; offset++ { previewDays = append(previewDays, now.In(location).AddDate(0, 0, offset)) } slots := make([]domain.TimeSlot, 0, 8) slots = append(slots, appointmentSlotsForDays(tenant, location, now, previewDays, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 4)...) 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 } // Catalog returns the public-facing service and staff catalog for a tenant's // booking page. Only active staff are listed. func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.PublicCatalog, error) { tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PublicCatalog{}, ErrTenantNotFound } return domain.PublicCatalog{}, err } services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicCatalog{}, err } staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) if err != nil { return domain.PublicCatalog{}, err } catalog := domain.PublicCatalog{ TenantSlug: tenant.Slug, Services: make([]domain.PublicService, 0, len(services)), Staff: make([]domain.PublicStaff, 0, len(staffRecords)), } for _, service := range services { catalog.Services = append(catalog.Services, domain.PublicService{ ID: service.ID, Name: service.Name, DurationMinutes: service.DurationMinutes, PriceCents: service.PriceCents, IntakeFields: shared.ParseIntakeFields(service.IntakeFields), }) } for _, staff := range staffRecords { if !staff.Active { continue } catalog.Staff = append(catalog.Staff, domain.PublicStaff{ ID: staff.ID, DisplayName: staff.DisplayName, }) } return catalog, 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) } if startsAt.Before(time.Now().UTC()) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidBooking) } customerName := strings.TrimSpace(request.CustomerName) customerEmail := strings.TrimSpace(request.CustomerEmail) customerPhone := strings.TrimSpace(request.CustomerPhone) notes := strings.TrimSpace(request.Notes) if len(customerName) < 2 || len(customerName) > 120 { return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking) } if _, err := mail.ParseAddress(customerEmail); err != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking) } if customerPhone != "" && len(customerPhone) > 30 { return domain.CreateBookingResponse{}, fmt.Errorf("%w: customerPhone must be at most 30 characters", ErrInvalidBooking) } if len(notes) > 1000 { return domain.CreateBookingResponse{}, fmt.Errorf("%w: notes must be at most 1000 characters", ErrInvalidBooking) } existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt) if err != nil { return domain.CreateBookingResponse{}, err } status := "confirmed" activityLabel := "" intakeJSON := "{}" switch request.BookingMode { case "appointment": if request.ServiceID == nil || request.ClassSessionID != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment bookings require serviceId only", ErrInvalidBooking) } service, ok, err := s.serviceForRequest(ctx, tenant.ID, *request.ServiceID) if err != nil { return domain.CreateBookingResponse{}, err } if !ok { return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking) } intakeJSON, err = shared.NormalizeIntakeAnswers(shared.ParseIntakeFields(service.IntakeFields), request.Intake) if err != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: %s", ErrInvalidBooking, err.Error()) } activityLabel = service.Name expectedDuration := time.Duration(service.DurationMinutes) * time.Minute if !startsAt.Add(expectedDuration).Equal(endsAt) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking) } if err := validateAppointmentTarget(ctx, s.repo, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil { return domain.CreateBookingResponse{}, err } for _, booking := range existing { if booking.Status == "cancelled" { continue } if resourcesCollide(booking, request.StaffID, request.LocationID) { return domain.CreateBookingResponse{}, ErrBookingConflict } } case "class": if request.ClassSessionID == nil || request.ServiceID != nil { return domain.CreateBookingResponse{}, fmt.Errorf("%w: class bookings require classSessionId only", ErrInvalidBooking) } session, ok, err := s.classSessionForRequest(ctx, tenant.ID, *request.ClassSessionID, startsAt) if err != nil { return domain.CreateBookingResponse{}, err } if !ok { return domain.CreateBookingResponse{}, fmt.Errorf("%w: classSessionId is not available for tenant", ErrInvalidBooking) } if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking) } activityLabel = session.Title classBookings := countClassBookings(existing, *request.ClassSessionID) if session.Capacity > 0 && classBookings >= session.Capacity { status = "waitlisted" } default: return domain.CreateBookingResponse{}, fmt.Errorf("%w: bookingMode must be appointment or class", ErrInvalidBooking) } 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: customerName, CustomerEmail: customerEmail, CustomerPhone: customerPhone, StartsAt: startsAt.UTC(), EndsAt: endsAt.UTC(), Status: status, Reference: db.Reference("BK", time.Now()), ManageToken: generateManageToken(), Notes: notes, Intake: intakeJSON, }) if err != nil { if db.IsExclusionViolation(err) { return domain.CreateBookingResponse{}, ErrBookingConflict } return domain.CreateBookingResponse{}, err } if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil { return domain.CreateBookingResponse{}, err } // Audit feed — best-effort like confirmation emails. _ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{ TenantID: tenant.ID, ActorKind: "customer", ActorName: customerName, Action: "booking.created", EntityType: "booking", EntityID: &created.ID, Summary: customerName + " — " + activityLabel, }) // Send confirmation emails (async - don't fail booking if email fails) go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt) return domain.CreateBookingResponse{ BookingID: created.ID, Reference: created.Reference, Status: created.Status, ManageURL: s.manageURL(created), }, nil } // validateAppointmentTarget ensures the requested appointment references // resources owned by the tenant and lands inside bookable time: the staff and // location must belong to the tenant, the window must fit an availability rule // the booking could claim, and it must not overlap a blocked day. func validateAppointmentTarget(ctx context.Context, repo db.Repository, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error { if staffID != nil { staff, err := repo.GetStaffByID(ctx, *staffID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) } return err } if staff.TenantID != tenant.ID || !staff.Active { return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) } } if locationID != nil { location, err := repo.GetLocationByID(ctx, *locationID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) } return err } if location.TenantID != tenant.ID { return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) } } rules, err := repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return err } blockedDays, err := repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC()) if err != nil { return err } location, err := time.LoadLocation(tenant.Timezone) if err != nil { location = time.UTC } if !fitsAvailabilityWindow(rules, location, staffID, startsAt, endsAt) { return fmt.Errorf("%w: requested time is outside bookable hours", ErrInvalidBooking) } if blockedAt(blockedDays, staffID, startsAt.UTC(), endsAt.UTC()) { return fmt.Errorf("%w: requested time falls on a blocked day", ErrInvalidBooking) } return nil } func (s *Service) manageURL(booking db.CreatedBooking) string { return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) } func (s *Service) bookingDescriptors(ctx context.Context, tenantID string, serviceID, locationID *string) (serviceName string, locationName string) { if serviceID != nil { if services, err := s.repo.ListServicesByTenant(ctx, tenantID); err == nil { for _, service := range services { if service.ID == *serviceID { serviceName = service.Name break } } } } if locationID != nil { if location, err := s.repo.GetLocationByID(ctx, *locationID); err == nil { locationName = location.Name } } return serviceName, locationName } func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, serviceID, locationID *string, customerName, customerEmail string, startsAt, endsAt time.Time) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Get brand profile for business details brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) brandColor := brand.PrimaryColor if brandColor == "" { brandColor = "#a65c3e" // Default terra color } serviceName, locationName := s.bookingDescriptors(ctx, tenant.ID, serviceID, locationID) emailData := notifications.BookingEmailData{ TenantID: tenant.ID, TenantName: tenant.Name, TenantSlug: tenant.Slug, BrandColor: brandColor, CustomerName: customerName, CustomerEmail: customerEmail, Service: serviceName, Location: locationName, Reference: booking.Reference, StartsAt: startsAt, EndsAt: endsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, ManagementURL: s.manageURL(booking), } // Send to customer s.notifier.SendBookingConfirmation(ctx, emailData) } func (s *Service) serviceForRequest(ctx context.Context, tenantID string, serviceID string) (db.ServiceRecord, bool, error) { services, err := s.repo.ListServicesByTenant(ctx, tenantID) if err != nil { return db.ServiceRecord{}, false, err } for _, service := range services { if service.ID == serviceID { return service, true, nil } } return db.ServiceRecord{}, false, nil } func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, classSessionID string, startsAt time.Time) (db.ClassSessionRecord, bool, error) { sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, startsAt.Add(-1*time.Minute), 64) if err != nil { return db.ClassSessionRecord{}, false, err } for _, session := range sessions { if session.ID == classSessionID { return session, true, nil } } return db.ClassSessionRecord{}, false, nil } func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.DashboardSummary{}, ErrTenantMembership } return domain.DashboardSummary{}, err } serviceNames := map[string]string{} serviceIntakeLabels := map[string]map[string]string{} if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil { for _, service := range services { serviceNames[service.ID] = service.Name serviceIntakeLabels[service.ID] = shared.IntakeLabels(service.IntakeFields) } } locationNames := map[string]string{} if locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID); err == nil { for _, location := range locations { locationNames[location.ID] = location.Name } } staffNames := map[string]string{} if staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID); err == nil { for _, member := range staff { staffNames[member.ID] = member.DisplayName } } toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { out := domain.UpcomingBooking{ ID: booking.ID, Reference: booking.Reference, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, Notes: booking.Notes, Intake: shared.ParseIntakeAnswers(booking.Intake), StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Status: booking.Status, ClassSessionID: booking.ClassSessionID, } if booking.ServiceID != nil { out.Service = serviceNames[*booking.ServiceID] out.IntakeLabels = serviceIntakeLabels[*booking.ServiceID] } if booking.LocationID != nil { out.Location = locationNames[*booking.LocationID] } if booking.StaffID != nil { out.StaffName = staffNames[*booking.StaffID] } return out } // Waitlist positions are FIFO per session, ordered by booking creation — // computed in memory to avoid one position query per queued booking. waitlistPositions := func(records []db.BookingRecord) map[string]int { positions := map[string]int{} perSession := map[string]int{} sorted := make([]db.BookingRecord, len(records)) copy(sorted, records) sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.Before(sorted[j].CreatedAt) }) for _, booking := range sorted { if booking.Status != "waitlisted" || booking.ClassSessionID == nil { continue } perSession[*booking.ClassSessionID]++ positions[booking.ID] = perSession[*booking.ClassSessionID] } return positions } 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 } previousMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -7), now) if err != nil { return domain.DashboardSummary{}, err } priorMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -14), now.AddDate(0, 0, -7)) if err != nil { return domain.DashboardSummary{}, err } upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd) if err != nil { return domain.DashboardSummary{}, err } upcomingPositions := waitlistPositions(upcomingRecords) upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords)) for _, booking := range upcomingRecords { if booking.Status == "cancelled" { continue } entry := toDomain(booking) entry.WaitlistPosition = upcomingPositions[booking.ID] upcoming = append(upcoming, entry) } if len(upcoming) > 5 { upcoming = upcoming[:5] } // Fetch all bookings for the last 30 days + next 30 days for chart and bookings page allFrom := now.AddDate(0, 0, -30) allTo := now.AddDate(0, 0, 30) allRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, allFrom, allTo) if err != nil { return domain.DashboardSummary{}, err } allPositions := waitlistPositions(allRecords) allBookings := make([]domain.UpcomingBooking, 0, len(allRecords)) for _, booking := range allRecords { entry := toDomain(booking) entry.WaitlistPosition = allPositions[booking.ID] allBookings = append(allBookings, entry) } brandName := membership.Tenant.Name if brand, err := s.repo.GetBrandProfile(ctx, membership.Tenant.ID); err == nil && strings.TrimSpace(brand.Name) != "" { brandName = brand.Name } scheduleCount := 0 if rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID); err == nil { scheduleCount = len(rules) } return domain.DashboardSummary{ TenantName: membership.Tenant.Name, TenantSlug: membership.Tenant.Slug, Locale: membership.Tenant.Locale, Timezone: membership.Tenant.Timezone, PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PublicBookingURL: "/book/" + membership.Tenant.Slug, SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount), KPIs: []domain.DashboardKPI{ dashboardKPI("bookings_this_week", "Bookings this week", metrics.BookingsCount, previousMetrics.BookingsCount, ""), dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""), dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""), dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"), dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""), }, UpcomingBookings: upcoming, AllBookings: allBookings, WidgetSnippets: widgetSnippets(membership.Tenant), Tracking: trackingStatus(s.repo, ctx, membership.Tenant), }, nil } // StaffMetrics returns per-staff appointment workload over a trailing // window — every active staff member appears, even with zero bookings. func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal, days int) (domain.StaffMetricsResponse, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.StaffMetricsResponse{}, ErrTenantMembership } return domain.StaffMetricsResponse{}, err } if days <= 0 || days > 365 { days = 30 } to := time.Now().UTC() from := to.AddDate(0, 0, -days) records, err := s.repo.GetStaffMetrics(ctx, membership.Tenant.ID, from, to) if err != nil { return domain.StaffMetricsResponse{}, err } byStaff := make(map[string]db.StaffMetricsRecord, len(records)) for _, rec := range records { byStaff[rec.StaffID] = rec } staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID) if err != nil { return domain.StaffMetricsResponse{}, err } rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID) if err != nil { return domain.StaffMetricsResponse{}, err } blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to) if err != nil { return domain.StaffMetricsResponse{}, err } loc, err := time.LoadLocation(membership.Tenant.Timezone) if err != nil { loc = time.UTC } capacity := staffCapacityMinutes(rules, blockedDays, from, to, loc) metrics := make([]domain.StaffMetric, 0, len(staff)) for _, member := range staff { rec := byStaff[member.ID] capMinutes := capacity[member.ID] utilization := 0 if capMinutes > 0 { utilization = int(float64(rec.BookedMinutes) / float64(capMinutes) * 100) } metrics = append(metrics, domain.StaffMetric{ StaffID: member.ID, DisplayName: member.DisplayName, Bookings: rec.BookingsCount, Completed: rec.CompletedCount, Cancelled: rec.CancellationsCount, BookedMinutes: rec.BookedMinutes, CapacityMinutes: capMinutes, UtilizationPercent: utilization, RevenueCents: rec.RevenueCents, }) } return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil } // ServiceMetrics aggregates appointment bookings per catalog service over a // trailing window — every service appears, even with zero bookings. Class // bookings carry no service and are counted separately. func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal, days int) (domain.ServiceMetricsResponse, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ServiceMetricsResponse{}, ErrTenantMembership } return domain.ServiceMetricsResponse{}, err } if days <= 0 || days > 365 { days = 30 } to := time.Now().UTC() from := to.AddDate(0, 0, -days) bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to) if err != nil { return domain.ServiceMetricsResponse{}, err } services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID) if err != nil { return domain.ServiceMetricsResponse{}, err } prices := make(map[string]int, len(services)) for _, service := range services { prices[service.ID] = service.PriceCents } type aggregate struct { bookings int completed int cancelled int revenue int64 } byService := make(map[string]*aggregate, len(services)) classBookings := 0 for _, booking := range bookingsList { if booking.ServiceID == nil { classBookings++ continue } agg := byService[*booking.ServiceID] if agg == nil { agg = &aggregate{} byService[*booking.ServiceID] = agg } agg.bookings++ switch booking.Status { case "cancelled": agg.cancelled++ case "completed": agg.completed++ } if booking.Status != "cancelled" { agg.revenue += int64(prices[*booking.ServiceID]) } } metrics := make([]domain.ServiceMetric, 0, len(services)) for _, service := range services { agg := byService[service.ID] if agg == nil { agg = &aggregate{} } metrics = append(metrics, domain.ServiceMetric{ ServiceID: service.ID, Name: service.Name, Bookings: agg.bookings, Completed: agg.completed, Cancelled: agg.cancelled, RevenueCents: agg.revenue, }) } return domain.ServiceMetricsResponse{ From: from, To: to, Services: metrics, ClassBookings: classBookings, }, nil } // staffCapacityMinutes sums each member's availability-rule minutes across the // window, evaluated on local calendar dates in the tenant timezone. Rules // without a staff assignment are tenant-wide and don't map to a member. A day // blocked for the member (or tenant-wide) contributes no capacity. func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, blockedDays []db.BlockedDayRecord, from, to time.Time, loc *time.Location) map[string]int { byStaff := map[string]int{} for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) { weekday := int(day.Weekday()) dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) for _, rule := range rules { if rule.StaffID == nil || rule.DayOfWeek != weekday { continue } if blockedAt(blockedDays, rule.StaffID, dayStart, dayStart.AddDate(0, 0, 1)) { continue } byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal) } } return byStaff } // localMinutesBetween returns the minutes between two "HH:MM[:SS]" local times, // or zero when the range is empty or malformed. func localMinutesBetween(starts, ends string) int { start := parseLocalMinutes(starts) end := parseLocalMinutes(ends) if end <= start { return 0 } return end - start } func parseLocalMinutes(hhmm string) int { parts := strings.SplitN(hhmm, ":", 3) if len(parts) < 2 { return 0 } hours, err := strconv.Atoi(parts[0]) if err != nil { return 0 } minutes, err := strconv.Atoi(parts[1]) if err != nil { return 0 } return hours*60 + minutes } // setupCompletion scores onboarding readiness across five equal-weight checks: // brand profile, location, service catalog, staff roster, and schedule rules. func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int { score := 0 if strings.TrimSpace(brandName) != "" { score += 20 } if locationCount > 0 { score += 20 } if serviceCount > 0 { score += 20 } if staffCount > 0 { score += 20 } if scheduleCount > 0 { score += 20 } return score } func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet { path := "/book/" + tenant.Slug return []domain.WidgetSnippet{ {Kind: "html", Code: fmt.Sprintf(``, path, tenant.Name)}, {Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return