mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Services and staff existed as schema + booking FK targets but had no management surface. Adds full CRUD: repository (pgx + in-memory with matching FK semantics), catalog service validation with tenant isolation, MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas, and dashboard screens for both entities. Dashboard summary now resolves staff names on bookings and computes setup completion from real configured state. Booking modal selects from the tenant's actual services; blocked days can be scoped per staff member. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
661 lines
22 KiB
Go
661 lines
22 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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{
|
|
{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)},
|
|
},
|
|
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."}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
now := time.Now().In(location)
|
|
var slots []domain.TimeSlot
|
|
|
|
for _, service := range services {
|
|
serviceSlots := 0
|
|
for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; 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,
|
|
})
|
|
serviceSlots++
|
|
if serviceSlots >= 4 {
|
|
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 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
|
|
}
|
|
}
|