package bookings import ( "context" "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 } 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) *Service { if notifier == nil { notifier = &noopNotifier{} } return &Service{repo: repo, notifier: notifier} } 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 sameResource(booking.StaffID, request.StaffID) || sameResource(booking.LocationID, 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()), 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, customerName, customerEmail, startsAt, endsAt) return domain.CreateBookingResponse{ BookingID: created.ID, Reference: created.Reference, Status: created.Status, }, nil } func (s *Service) sendBookingConfirmationEmails(tenant db.TenantRecord, booking db.CreatedBooking, 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 } managementURL := fmt.Sprintf("https://bookra.eu/manage/%s?token=%s", booking.Reference, booking.Reference) emailData := notifications.BookingEmailData{ 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 Reference: booking.Reference, StartsAt: startsAt, EndsAt: endsAt, Timezone: tenant.Timezone, Locale: tenant.Locale, ManagementURL: managementURL, } // 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.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 } 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 { upcoming = append(upcoming, domain.UpcomingBooking{ Reference: booking.Reference, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Status: booking.Status, }) } 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, domain.UpcomingBooking{ Reference: booking.Reference, CustomerName: booking.CustomerName, CustomerEmail: booking.CustomerEmail, StartsAt: booking.StartsAt, EndsAt: booking.EndsAt, Status: booking.Status, }) } 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: 100, 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 } func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet { path := "/book/" + tenant.Slug return []domain.WidgetSnippet{ {Kind: "html", Code: fmt.Sprintf(``, path, tenant.Name)}, {Kind: "react", Code: fmt.Sprintf(`export function BookraWidget() { return