mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"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")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
status := "confirmed"
|
||||
if request.BookingMode == "class" && request.ClassSessionID != nil {
|
||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, startsAt.Add(-1*time.Minute), 16)
|
||||
if err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
sessionCapacity := int32(0)
|
||||
for _, session := range classSessions {
|
||||
if session.ID == *request.ClassSessionID {
|
||||
sessionCapacity = session.Capacity
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCapacity > 0 && classBookings >= sessionCapacity {
|
||||
status = "waitlisted"
|
||||
}
|
||||
} else {
|
||||
for _, booking := range existing {
|
||||
if booking.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
if sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, request.LocationID) {
|
||||
return domain.CreateBookingResponse{}, ErrBookingConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: request.CustomerName,
|
||||
CustomerEmail: request.CustomerEmail,
|
||||
StartsAt: startsAt.UTC(),
|
||||
EndsAt: endsAt.UTC(),
|
||||
Status: status,
|
||||
Reference: db.Reference("BK", time.Now()),
|
||||
Notes: request.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: request.CustomerName,
|
||||
CustomerEmail: request.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
|
||||
}
|
||||
}
|
||||
|
||||
return domain.CreateBookingResponse{
|
||||
BookingID: created.ID,
|
||||
Reference: created.Reference,
|
||||
Status: created.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DashboardSummary(ctx context.Context, principal domain.Principal) (domain.DashboardSummary, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.DashboardSummary{}, ErrTenantMembership
|
||||
}
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return domain.DashboardSummary{
|
||||
TenantName: membership.Tenant.Name,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
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)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)) {
|
||||
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,
|
||||
})
|
||||
if len(slots) >= 6 {
|
||||
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 sameResource(booking.StaffID, staffID) || sameResource(booking.LocationID, locationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameResource(left *string, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestCreateAppointmentRejectsConflict(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var appointment domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "appointment" {
|
||||
appointment = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if appointment.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
|
||||
first, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "First",
|
||||
CustomerEmail: "first@example.com",
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
if first.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed, got %s", first.Status)
|
||||
}
|
||||
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "Second",
|
||||
CustomerEmail: "second@example.com",
|
||||
StartsAt: appointment.StartsAt,
|
||||
EndsAt: appointment.EndsAt,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if err != ErrBookingConflict {
|
||||
t.Fatalf("expected ErrBookingConflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var classSlot domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "class" {
|
||||
classSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if classSlot.ClassSessionID == nil {
|
||||
t.Fatal("expected class slot")
|
||||
}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: classSlot.ClassSessionID,
|
||||
LocationID: classSlot.LocationID,
|
||||
CustomerName: "Capacity",
|
||||
CustomerEmail: "capacity@example.com",
|
||||
StartsAt: classSlot.StartsAt,
|
||||
EndsAt: classSlot.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create within capacity: %v", err)
|
||||
}
|
||||
if response.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed within capacity, got %s", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "class",
|
||||
ClassSessionID: classSlot.ClassSessionID,
|
||||
LocationID: classSlot.LocationID,
|
||||
CustomerName: "Waitlist",
|
||||
CustomerEmail: "waitlist@example.com",
|
||||
StartsAt: classSlot.StartsAt,
|
||||
EndsAt: classSlot.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create waitlist: %v", err)
|
||||
}
|
||||
if response.Status != "waitlisted" {
|
||||
t.Fatalf("expected waitlisted, got %s", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
if len(availability.Slots) == 0 {
|
||||
t.Fatal("expected slots")
|
||||
}
|
||||
|
||||
for _, slot := range availability.Slots {
|
||||
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse startsAt: %v", err)
|
||||
}
|
||||
if startsAt.Before(time.Now().UTC().Add(90 * time.Minute)) {
|
||||
t.Fatalf("expected upcoming slot, got %s", slot.StartsAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier")
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
|
||||
var appointment domain.TimeSlot
|
||||
for _, slot := range availability.Slots {
|
||||
if slot.Mode == "appointment" {
|
||||
appointment = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if appointment.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
|
||||
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: appointment.ServiceID,
|
||||
StaffID: appointment.StaffID,
|
||||
LocationID: appointment.LocationID,
|
||||
CustomerName: "Reminder",
|
||||
CustomerEmail: "reminder@example.com",
|
||||
StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339),
|
||||
EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
reminders, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(365*24*time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminder jobs: %v", err)
|
||||
}
|
||||
if len(reminders) == 0 {
|
||||
t.Fatal("expected reminder job to be scheduled")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user