Files
Bookra/apps/backend/internal/bookings/service.go
T
Tomas DvorakandDevin 09f780e7a6 feat(catalog): add service and staff management end-to-end
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>
2026-09-18 08:52:25 +02:00

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
}
}