refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

@@ -2,7 +2,10 @@ package bookings
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"bookra/apps/backend/internal/db"
@@ -17,8 +20,9 @@ var (
)
type CustomerService struct {
repo db.Repository
notifier CustomerNotifier
repo db.Repository
notifier CustomerNotifier
frontendURL string
}
type CustomerNotifier interface {
@@ -26,11 +30,14 @@ type CustomerNotifier interface {
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
}
func NewCustomerService(repo db.Repository, notifier CustomerNotifier) *CustomerService {
func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendURL string) *CustomerService {
if notifier == nil {
notifier = &customerNoopNotifier{}
}
return &CustomerService{repo: repo, notifier: notifier}
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
return &CustomerService{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")}
}
type customerNoopNotifier struct{}
@@ -43,29 +50,70 @@ func (n *customerNoopNotifier) SendBookingCancellation(ctx context.Context, data
return nil
}
// authorize compares the caller-supplied management token against the stored
// per-booking token in constant time. Bookings without a token (created before
// the feature) cannot be managed through the public endpoints.
func authorize(booking db.BookingRecord, token string) error {
if booking.ManageToken == "" || token == "" {
return ErrUnauthorized
}
if subtle.ConstantTimeCompare([]byte(token), []byte(booking.ManageToken)) != 1 {
return ErrUnauthorized
}
return nil
}
func (s *CustomerService) managementURL(booking db.BookingRecord) string {
return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken)
}
func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) {
if booking.ServiceID != nil {
if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil {
for _, service := range services {
if service.ID == *booking.ServiceID {
serviceName = service.Name
break
}
}
}
}
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
}
}
return serviceName, locationName
}
// GetBookingByReference returns booking details for customer management link
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return domain.CustomerBookingView{}, ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return domain.CustomerBookingView{}, err
}
// Get tenant details for business name
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
if err != nil {
return domain.CustomerBookingView{}, err
}
serviceName, locationName := s.descriptors(ctx, booking)
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service", // Would get from service ID in full implementation
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: "Location", // Would get from location ID in full implementation
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
}, nil
}
@@ -75,6 +123,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
if err != nil {
return ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
@@ -98,6 +149,20 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
return ErrInvalidReschedule
}
// Re-check resource conflicts at the requested window, excluding self.
existing, err := s.repo.ListBookingsByTenantBetween(ctx, booking.TenantID, newStartsAt, newEndsAt)
if err != nil {
return err
}
for _, other := range existing {
if other.ID == booking.ID || other.Status == "cancelled" || other.Status == "waitlisted" {
continue
}
if resourcesCollide(other, booking.StaffID, booking.LocationID) {
return ErrBookingConflict
}
}
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
return err
}
@@ -114,6 +179,9 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
if err != nil {
return ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return err
}
if booking.Status == "cancelled" {
return ErrBookingCancelled
@@ -144,22 +212,23 @@ func (s *CustomerService) sendRescheduleEmail(booking db.BookingRecord, newStart
brandColor = "#a65c3e"
}
managementURL := "https://bookra.eu/manage/" + booking.Reference + "?token=" + booking.Reference
serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: newStartsAt,
EndsAt: newEndsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
ManagementURL: s.managementURL(booking),
}
s.notifier.SendBookingReschedule(ctx, emailData)
@@ -180,14 +249,17 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
brandColor = "#a65c3e"
}
serviceName, locationName := s.descriptors(ctx, booking)
emailData := notifications.BookingEmailData{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BrandColor: brandColor,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: "Service",
Location: "Location",
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
@@ -0,0 +1,113 @@
package bookings
import (
"context"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func newBookingFixture(t *testing.T, repo *db.MemoryRepository) db.BookingRecord {
t.Helper()
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
CustomerName: "Test Customer",
CustomerEmail: "[email protected]",
StartsAt: time.Now().Add(24 * time.Hour),
EndsAt: time.Now().Add(25 * time.Hour),
Status: "confirmed",
Reference: "BOK-TEST-1",
ManageToken: "secret-manage-token",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
booking, err := repo.GetBookingByReference(context.Background(), created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func TestGetBookingRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for missing token, got %v", err)
}
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for wrong token, got %v", err)
}
view, err := svc.GetBookingByReference(context.Background(), booking.Reference, "secret-manage-token")
if err != nil {
t.Fatalf("expected authorized view, got %v", err)
}
if view.Reference != booking.Reference {
t.Fatalf("expected reference %s, got %s", booking.Reference, view.Reference)
}
}
func TestRescheduleRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
req := domain.RescheduleBookingRequest{
NewStartsAt: time.Now().Add(48 * time.Hour).Format(time.RFC3339),
NewEndsAt: time.Now().Add(49 * time.Hour).Format(time.RFC3339),
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
func TestCancelRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if err := svc.CancelBooking(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "secret-manage-token"); err != nil {
t.Fatalf("expected successful cancel, got %v", err)
}
}
func TestRescheduleRejectsConflictingSlot(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
base := time.Now().Add(48 * time.Hour)
// Existing booking in the same tenant occupying the target slot.
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
StartsAt: base,
EndsAt: base.Add(time.Hour),
Status: "confirmed",
Reference: "BOK-OCCUPIED",
ManageToken: "token-a",
})
if err != nil {
t.Fatalf("create occupying booking: %v", err)
}
booking := newBookingFixture(t, repo)
err = svc.RescheduleBooking(context.Background(), booking.Reference, domain.RescheduleBookingRequest{
NewStartsAt: base.Add(30 * time.Minute).Format(time.RFC3339), // overlaps BOK-OCCUPIED
NewEndsAt: base.Add(90 * time.Minute).Format(time.RFC3339),
}, "secret-manage-token")
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
+155 -70
View File
@@ -2,6 +2,8 @@ package bookings
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/mail"
@@ -26,8 +28,9 @@ var (
)
type Service struct {
repo db.Repository
notifier Notifier
repo db.Repository
notifier Notifier
frontendURL string
}
type Notifier interface {
@@ -35,11 +38,14 @@ type Notifier interface {
SendBusinessNotification(ctx context.Context, businessEmail string, data notifications.BookingEmailData) error
}
func NewService(repo db.Repository, notifier Notifier) *Service {
func NewService(repo db.Repository, notifier Notifier, frontendURL string) *Service {
if notifier == nil {
notifier = &noopNotifier{}
}
return &Service{repo: repo, notifier: notifier}
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
return &Service{repo: repo, notifier: notifier, frontendURL: strings.TrimRight(frontendURL, "/")}
}
type noopNotifier struct{}
@@ -163,7 +169,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if booking.Status == "cancelled" {
continue
}
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) {
if resourcesCollide(booking, request.StaffID, request.LocationID) {
return domain.CreateBookingResponse{}, ErrBookingConflict
}
}
@@ -203,6 +209,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
EndsAt: endsAt.UTC(),
Status: status,
Reference: db.Reference("BK", time.Now()),
ManageToken: generateManageToken(),
Notes: notes,
})
if err != nil {
@@ -234,16 +241,40 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
// Send confirmation emails (async - don't fail booking if email fails)
go s.sendBookingConfirmationEmails(tenant, created, customerName, customerEmail, startsAt, endsAt)
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) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, customerName, customerEmail string, startsAt, endsAt time.Time) {
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()
@@ -254,22 +285,23 @@ func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking
brandColor = "#a65c3e" // Default terra color
}
managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference)
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: "Service", // Would lookup from booking.ServiceID in full implementation
Location: "Location", // Would lookup from booking.LocationID in full implementation
Service: serviceName,
Location: locationName,
Reference: booking.Reference,
StartsAt: startsAt,
EndsAt: endsAt,
Timezone: tenant.Timezone,
Locale: tenant.Locale,
ManagementURL: managementURL,
ManagementURL: s.manageURL(booking),
}
// Send to customer
@@ -303,7 +335,7 @@ func (s *Service) classSessionForRequest(ctx context.Context, tenantID string, c
}
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DashboardSummary{}, ErrTenantMembership
@@ -311,6 +343,38 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
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
}
}
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]
}
return out
}
now := time.Now().UTC()
weekEnd := now.AddDate(0, 0, 7)
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
@@ -323,14 +387,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
for _, booking := range upcomingRecords {
upcoming = append(upcoming, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
if booking.Status == "cancelled" {
continue
}
upcoming = append(upcoming, toDomain(booking))
}
if len(upcoming) > 5 {
upcoming = upcoming[:5]
@@ -345,14 +405,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range allRecords {
allBookings = append(allBookings, domain.UpcomingBooking{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Status: booking.Status,
})
allBookings = append(allBookings, toDomain(booking))
}
return domain.DashboardSummary{
@@ -407,52 +460,55 @@ func generateAppointmentSlots(
location = time.UTC
}
service := services[0]
now := time.Now().In(location)
var slots []domain.TimeSlot
for dayOffset := 0; dayOffset < 7 && len(slots) < 6; dayOffset++ {
day := now.AddDate(0, 0, dayOffset)
for _, rule := range rules {
if int(day.Weekday()) != rule.DayOfWeek {
continue
}
startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location)
if err != nil {
continue
}
endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location)
if err != nil {
continue
}
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location)
windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location)
step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute
duration := time.Duration(service.DurationMinutes) * time.Minute
for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) {
if slotStart.Before(now.Add(2 * time.Hour)) {
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
}
slotEnd := slotStart.Add(duration)
if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) {
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
}
serviceID := service.ID
slots = append(slots, domain.TimeSlot{
ServiceID: &serviceID,
StaffID: rule.StaffID,
StartsAt: slotStart.UTC().Format(time.RFC3339),
EndsAt: slotEnd.UTC().Format(time.RFC3339),
Mode: "appointment",
Label: service.Name,
})
if len(slots) >= 6 {
break
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
}
}
}
}
@@ -491,18 +547,47 @@ func collides(bookings []db.BookingRecord, staffID *string, locationID *string,
if !(booking.StartsAt.Before(endsAt) && booking.EndsAt.After(startsAt)) {
continue
}
if sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) {
if resourcesCollide(booking, staffID, locationID) {
return true
}
}
return false
}
func sameResource(left *string, right *string) bool {
if left == nil || right == nil {
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
}
return *left == *right
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 {
@@ -12,7 +12,7 @@ import (
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -69,7 +69,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) {
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -126,7 +126,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
@@ -143,7 +143,7 @@ func TestCreateAppointmentRequiresTenantService(t *testing.T) {
func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
@@ -162,7 +162,7 @@ func TestCreateClassRequiresExistingSession(t *testing.T) {
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {
@@ -185,7 +185,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil)
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier")
if err != nil {