mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
938 lines
31 KiB
Go
938 lines
31 KiB
Go
package bookings
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"sort"
|
|
"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")
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
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"
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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,
|
|
})
|
|
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: customerName,
|
|
CustomerEmail: 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
|
|
}
|
|
}
|
|
|
|
// 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{}
|
|
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
|
|
for _, service := range services {
|
|
serviceNames[service.ID] = service.Name
|
|
}
|
|
}
|
|
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,
|
|
StartsAt: booking.StartsAt,
|
|
EndsAt: booking.EndsAt,
|
|
Status: booking.Status,
|
|
}
|
|
if booking.ServiceID != nil {
|
|
out.Service = serviceNames[*booking.ServiceID]
|
|
}
|
|
if booking.LocationID != nil {
|
|
out.Location = locationNames[*booking.LocationID]
|
|
}
|
|
if booking.StaffID != nil {
|
|
out.StaffName = staffNames[*booking.StaffID]
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|
|
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
|
for _, booking := range upcomingRecords {
|
|
if booking.Status == "cancelled" {
|
|
continue
|
|
}
|
|
upcoming = append(upcoming, toDomain(booking))
|
|
}
|
|
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
|
|
}
|
|
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
|
|
for _, booking := range allRecords {
|
|
allBookings = append(allBookings, toDomain(booking))
|
|
}
|
|
|
|
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, "%"),
|
|
},
|
|
UpcomingBookings: upcoming,
|
|
AllBookings: allBookings,
|
|
WidgetSnippets: widgetSnippets(membership.Tenant),
|
|
Tracking: trackingStatus(s.repo, ctx, membership.Tenant),
|
|
}, nil
|
|
}
|
|
|
|
// 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(`<iframe src="%s" title="%s booking" style="width:100%%;min-height:760px;border:0;"></iframe>`, path, tenant.Name)},
|
|
{Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return <iframe src="%s" title="%s booking" style={{ width: "100%%", minHeight: 760, border: 0 }} />; }`, path, tenant.Name)},
|
|
{Kind: "typescript", Code: fmt.Sprintf(`const bookraWidgetUrl = new URL("%s", window.location.origin);`, path)},
|
|
}
|
|
}
|
|
|
|
func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRecord) domain.TrackingStatus {
|
|
brand, err := repo.GetBrandProfile(ctx, tenant.ID)
|
|
if err != nil || strings.TrimSpace(brand.UmamiSiteID) == "" {
|
|
return domain.TrackingStatus{Provider: "umami", Connected: false, Message: "Umami tracking is not connected."}
|
|
}
|
|
return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."}
|
|
}
|
|
|
|
// appointmentSlotsForDays generates appointment slots for the given tenant-local
|
|
// days. When staffFilter is set it acts as the customer's staff preference:
|
|
// tenant-wide rules (staffID nil) still apply and produce slots assigned to the
|
|
// preferred staff member, while rules scoped to another staff member are
|
|
// skipped. capPerService limits total slots per service across all days; a cap
|
|
// of 0 means unlimited.
|
|
func appointmentSlotsForDays(
|
|
tenant db.TenantRecord,
|
|
location *time.Location,
|
|
now time.Time,
|
|
days []time.Time,
|
|
services []db.ServiceRecord,
|
|
rules []db.AvailabilityRuleRecord,
|
|
blockedDays []db.BlockedDayRecord,
|
|
staffNames map[string]string,
|
|
existing []db.BookingRecord,
|
|
staffFilter *string,
|
|
capPerService int,
|
|
) []domain.TimeSlot {
|
|
if len(services) == 0 || len(rules) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var slots []domain.TimeSlot
|
|
for _, service := range services {
|
|
serviceSlots := 0
|
|
for _, day := range days {
|
|
if capPerService > 0 && serviceSlots >= capPerService {
|
|
break
|
|
}
|
|
for _, rule := range rules {
|
|
if int(day.Weekday()) != rule.DayOfWeek {
|
|
continue
|
|
}
|
|
if staffFilter != nil && rule.StaffID != nil && *rule.StaffID != *staffFilter {
|
|
continue
|
|
}
|
|
|
|
windowStart, windowEnd, ok := ruleWindow(day, rule, location)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
slotStaffID := rule.StaffID
|
|
if staffFilter != nil {
|
|
slotStaffID = staffFilter
|
|
}
|
|
|
|
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
|
|
duration := time.Duration(service.DurationMinutes) * time.Minute
|
|
for slotStart := windowStart; !slotStart.Add(duration).After(windowEnd); slotStart = slotStart.Add(step) {
|
|
if slotStart.Before(now.Add(2 * time.Hour)) {
|
|
continue
|
|
}
|
|
|
|
slotEnd := slotStart.Add(duration)
|
|
if collides(existing, slotStaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
|
|
continue
|
|
}
|
|
if blockedAt(blockedDays, slotStaffID, slotStart.UTC(), slotEnd.UTC()) {
|
|
continue
|
|
}
|
|
|
|
serviceID := service.ID
|
|
slot := domain.TimeSlot{
|
|
ServiceID: &serviceID,
|
|
StaffID: slotStaffID,
|
|
StartsAt: slotStart.UTC().Format(time.RFC3339),
|
|
EndsAt: slotEnd.UTC().Format(time.RFC3339),
|
|
Mode: "appointment",
|
|
Label: service.Name,
|
|
}
|
|
if slotStaffID != nil {
|
|
if name, ok := staffNames[*slotStaffID]; ok {
|
|
slot.StaffName = &name
|
|
}
|
|
}
|
|
slots = append(slots, slot)
|
|
serviceSlots++
|
|
if capPerService > 0 && serviceSlots >= capPerService {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return slots
|
|
}
|
|
|
|
// ruleWindow resolves a weekly availability rule to concrete start/end times on
|
|
// the given day in the tenant's timezone.
|
|
func ruleWindow(day time.Time, rule db.AvailabilityRuleRecord, location *time.Location) (time.Time, time.Time, bool) {
|
|
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, false
|
|
}
|
|
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, false
|
|
}
|
|
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)
|
|
return windowStart, windowEnd, true
|
|
}
|
|
|
|
// blockedAt reports whether a tenant-wide blocked day or one scoped to the
|
|
// given staff member overlaps the requested window.
|
|
func blockedAt(blockedDays []db.BlockedDayRecord, staffID *string, startsAt, endsAt time.Time) bool {
|
|
for _, blocked := range blockedDays {
|
|
if !(blocked.StartsAt.Before(endsAt) && blocked.EndsAt.After(startsAt)) {
|
|
continue
|
|
}
|
|
if blocked.StaffID == nil || (staffID != nil && *blocked.StaffID == *staffID) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fitsAvailabilityWindow reports whether the requested window falls inside an
|
|
// availability rule the booking could claim: a tenant-wide rule, or one scoped
|
|
// to the requested staff member.
|
|
func fitsAvailabilityWindow(rules []db.AvailabilityRuleRecord, location *time.Location, staffID *string, startsAt, endsAt time.Time) bool {
|
|
day := startsAt.In(location)
|
|
weekday := int(day.Weekday())
|
|
for _, rule := range rules {
|
|
if rule.DayOfWeek != weekday {
|
|
continue
|
|
}
|
|
if rule.StaffID != nil && (staffID == nil || *rule.StaffID != *staffID) {
|
|
continue
|
|
}
|
|
windowStart, windowEnd, ok := ruleWindow(day, rule, location)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !day.Before(windowStart) && !endsAt.In(location).After(windowEnd) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 resourcesCollide(booking, staffID, locationID) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resourcesCollide reports whether an existing booking shares the resource a
|
|
// new booking would consume. A booking with neither staff nor location
|
|
// assignment occupies the tenant's single implicit resource, so it conflicts
|
|
// with any other unassigned booking. Explicit assignments only collide when
|
|
// they match exactly.
|
|
func resourcesCollide(booking db.BookingRecord, staffID *string, locationID *string) bool {
|
|
if booking.StaffID != nil && staffID != nil && *booking.StaffID == *staffID {
|
|
return true
|
|
}
|
|
if booking.LocationID != nil && locationID != nil && *booking.LocationID == *locationID {
|
|
return true
|
|
}
|
|
return booking.StaffID == nil && staffID == nil && booking.LocationID == nil && locationID == nil
|
|
}
|
|
|
|
func generateManageToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(fmt.Sprintf("generate manage token: %v", err))
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// GenerateManageToken returns a new cryptographically random booking
|
|
// management token. Exported for catalog dashboard bookings.
|
|
func GenerateManageToken() string {
|
|
return generateManageToken()
|
|
}
|
|
|
|
// AppointmentResourceConflict reports whether an existing booking shares the
|
|
// resource a new appointment would consume. Exported for catalog dashboard
|
|
// bookings.
|
|
func AppointmentResourceConflict(booking db.BookingRecord, staffID *string, locationID *string) bool {
|
|
return resourcesCollide(booking, staffID, locationID)
|
|
}
|
|
|
|
func sameSecond(left time.Time, right time.Time) bool {
|
|
return left.UTC().Truncate(time.Second).Equal(right.UTC().Truncate(time.Second))
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func dashboardKPI(code, label string, current int, previous int, suffix string) domain.DashboardKPI {
|
|
delta := current - previous
|
|
change := "0"
|
|
trend := "neutral"
|
|
if delta > 0 {
|
|
change = fmt.Sprintf("+%d", delta)
|
|
trend = "up"
|
|
} else if delta < 0 {
|
|
change = fmt.Sprintf("%d", delta)
|
|
trend = "down"
|
|
}
|
|
if suffix != "" && change != "0" {
|
|
change += suffix
|
|
}
|
|
return domain.DashboardKPI{Code: code, Label: label, Value: fmt.Sprintf("%d%s", current, suffix), Change: change, Trend: trend}
|
|
}
|