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