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,