mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
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:
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user