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

+162 -42
View File
@@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
@@ -16,6 +19,7 @@ var (
ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found")
ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrPlanLimitReached = errors.New("plan limit reached")
@@ -44,7 +48,7 @@ func NewService(repo db.Repository, billingService interface {
// ============================================
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -56,21 +60,25 @@ func (s *Service) ListLocations(ctx context.Context, principal domain.Principal)
locations := make([]domain.Location, len(records))
for i, rec := range records {
locations[i] = domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: "room", // Default type
Capacity: 10, // Default capacity
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
locations[i] = locationToDomain(rec)
}
return locations, nil
}
func locationToDomain(rec db.LocationRecord) domain.Location {
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: rec.Type,
Capacity: rec.Capacity,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}
}
func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal, req domain.CreateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
@@ -87,9 +95,20 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
}
}
locationType := strings.TrimSpace(req.Type)
if locationType == "" {
locationType = "room"
}
capacity := req.Capacity
if capacity <= 0 {
capacity = 1
}
params := db.CreateLocationParams{
TenantID: membership.Tenant.ID,
Name: req.Name,
Type: locationType,
Capacity: capacity,
Timezone: membership.Tenant.Timezone,
}
@@ -110,19 +129,11 @@ func (s *Service) CreateLocation(ctx context.Context, principal domain.Principal
}
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
return locationToDomain(rec), nil
}
func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal, locationID string, req domain.UpdateLocationRequest) (domain.Location, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Location{}, ErrTenantMembership
}
@@ -140,25 +151,23 @@ func (s *Service) UpdateLocation(ctx context.Context, principal domain.Principal
if req.Name != "" {
params.Name = &req.Name
}
if req.Type != "" {
params.Type = &req.Type
}
if req.Capacity > 0 {
params.Capacity = &req.Capacity
}
rec, err := s.repo.UpdateLocation(ctx, locationID, params)
if err != nil {
return domain.Location{}, err
}
return domain.Location{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
Type: req.Type,
Capacity: 10,
Timezone: rec.Timezone,
CreatedAt: rec.CreatedAt,
}, nil
return locationToDomain(rec), nil
}
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -179,7 +188,7 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
// ============================================
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -205,7 +214,7 @@ func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principa
}
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
@@ -241,7 +250,7 @@ func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Princip
}
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.BlockedDay{}, ErrTenantMembership
}
@@ -288,7 +297,7 @@ func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Princip
}
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -318,7 +327,7 @@ func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Princip
// ============================================
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -353,7 +362,7 @@ func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal,
}
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
@@ -390,7 +399,7 @@ func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal
}
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Customer{}, ErrTenantMembership
}
@@ -444,7 +453,7 @@ func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal
}
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
@@ -465,7 +474,7 @@ func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal
// ============================================
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
@@ -488,18 +497,129 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip
}
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
params := db.UpdateWorkingHoursParams{}
params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen}
if req.Open != "" {
if _, err := time.Parse("15:04", req.Open); err != nil {
return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking)
}
params.StartsLocal = &req.Open
}
if req.Close != "" {
if _, err := time.Parse("15:04", req.Close); err != nil {
return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking)
}
params.EndsLocal = &req.Close
}
return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params)
}
// CreateBooking creates a manual booking from the dashboard. The service name
// is resolved against the tenant's services; when it matches nothing, the
// tenant's first service is used so the booking still carries a duration.
func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, req domain.DashboardBookingRequest) (domain.UpcomingBooking, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.UpcomingBooking{}, ErrTenantMembership
}
tenant := membership.Tenant
startsAt, err := time.Parse(time.RFC3339, req.StartsAt)
if err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidBooking)
}
customerName := strings.TrimSpace(req.CustomerName)
customerEmail := strings.TrimSpace(req.CustomerEmail)
if len(customerName) < 2 || len(customerName) > 120 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerName must be between 2 and 120 characters", ErrInvalidBooking)
}
if _, err := mail.ParseAddress(customerEmail); err != nil {
return domain.UpcomingBooking{}, fmt.Errorf("%w: customerEmail must be valid", ErrInvalidBooking)
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return domain.UpcomingBooking{}, err
}
if len(services) == 0 {
return domain.UpcomingBooking{}, fmt.Errorf("%w: tenant has no services configured", ErrInvalidBooking)
}
selected := services[0]
requested := strings.TrimSpace(req.Service)
for _, service := range services {
if strings.EqualFold(service.Name, requested) || service.ID == requested {
selected = service
break
}
}
endsAt := startsAt.Add(time.Duration(selected.DurationMinutes) * time.Minute)
// Upsert the customer record so the booking shows up under the customers page.
customer, err := s.repo.GetCustomerByEmail(ctx, tenant.ID, customerEmail)
if err != nil {
customer, err = s.repo.CreateCustomer(ctx, db.CreateCustomerParams{
TenantID: tenant.ID,
Name: customerName,
Email: customerEmail,
Status: "active",
})
if err != nil {
return domain.UpcomingBooking{}, err
}
}
customerPhone := strings.TrimSpace(req.CustomerPhone)
if customerPhone != "" && (customer.Phone == nil || *customer.Phone != customerPhone) {
_, _ = s.repo.UpdateCustomer(ctx, customer.ID, db.UpdateCustomerParams{Phone: &customerPhone})
}
existing, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, startsAt, endsAt)
if err != nil {
return domain.UpcomingBooking{}, err
}
for _, booking := range existing {
if booking.Status == "cancelled" || booking.Status == "waitlisted" {
continue
}
if bookings.AppointmentResourceConflict(booking, nil, nil) {
return domain.UpcomingBooking{}, ErrBookingConflict
}
}
created, err := s.repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenant.ID,
ServiceID: &selected.ID,
BookingMode: "appointment",
CustomerName: customerName,
CustomerEmail: customerEmail,
CustomerPhone: customerPhone,
StartsAt: startsAt.UTC(),
EndsAt: endsAt.UTC(),
Status: "confirmed",
Reference: db.Reference("BK", time.Now()),
ManageToken: bookings.GenerateManageToken(),
Notes: strings.TrimSpace(req.Notes),
})
if err != nil {
return domain.UpcomingBooking{}, err
}
return domain.UpcomingBooking{
ID: created.ID,
Reference: created.Reference,
CustomerName: customerName,
CustomerEmail: customerEmail,
Service: selected.Name,
StartsAt: startsAt,
EndsAt: endsAt,
Status: created.Status,
Notes: strings.TrimSpace(req.Notes),
}, nil
}