mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
- 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>
626 lines
18 KiB
Go
626 lines
18 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookra/apps/backend/internal/bookings"
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
)
|
|
|
|
var (
|
|
ErrLocationNotFound = errors.New("location not found")
|
|
ErrBlockedDayNotFound = errors.New("blocked day not found")
|
|
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")
|
|
)
|
|
|
|
type Service struct {
|
|
repo db.Repository
|
|
billingService interface {
|
|
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
|
}
|
|
notificationService interface {
|
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
|
}
|
|
}
|
|
|
|
func NewService(repo db.Repository, billingService interface {
|
|
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
|
}, notificationService interface {
|
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
|
}) *Service {
|
|
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
|
|
}
|
|
|
|
// ============================================
|
|
// LOCATION / ZONE MANAGEMENT
|
|
// ============================================
|
|
|
|
func (s *Service) ListLocations(ctx context.Context, principal domain.Principal) ([]domain.Location, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return nil, ErrTenantMembership
|
|
}
|
|
|
|
records, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
locations := make([]domain.Location, len(records))
|
|
for i, rec := range records {
|
|
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.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.Location{}, ErrTenantMembership
|
|
}
|
|
|
|
// Check plan entitlements for location limit
|
|
if s.billingService != nil {
|
|
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
|
if err == nil && entitlements.MaxLocations > 0 {
|
|
// Count existing locations
|
|
locations, err := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
|
if err == nil && len(locations) >= entitlements.MaxLocations {
|
|
return domain.Location{}, fmt.Errorf("%w: location limit reached (%d/%d). Upgrade your plan to add more locations.", ErrPlanLimitReached, len(locations), entitlements.MaxLocations)
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
rec, err := s.repo.CreateLocation(ctx, params)
|
|
if err != nil {
|
|
return domain.Location{}, err
|
|
}
|
|
|
|
// Send usage warning if at 80%+ of limit
|
|
if s.notificationService != nil && s.billingService != nil {
|
|
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
|
|
if err == nil && entitlements.MaxLocations > 0 {
|
|
locations, _ := s.repo.ListLocationsByTenant(ctx, membership.Tenant.ID)
|
|
usagePercent := (len(locations) * 100) / entitlements.MaxLocations
|
|
if usagePercent >= 80 {
|
|
_ = s.notificationService.SendUsageWarning(ctx, membership.Tenant.ID, len(locations), entitlements.MaxLocations, usagePercent)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.Location{}, ErrTenantMembership
|
|
}
|
|
|
|
// Verify location belongs to tenant
|
|
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
|
if err != nil {
|
|
return domain.Location{}, ErrLocationNotFound
|
|
}
|
|
if loc.TenantID != membership.Tenant.ID {
|
|
return domain.Location{}, ErrLocationNotFound
|
|
}
|
|
|
|
params := db.UpdateLocationParams{}
|
|
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 locationToDomain(rec), nil
|
|
}
|
|
|
|
func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal, locationID string) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
|
|
loc, err := s.repo.GetLocationByID(ctx, locationID)
|
|
if err != nil {
|
|
return ErrLocationNotFound
|
|
}
|
|
if loc.TenantID != membership.Tenant.ID {
|
|
return ErrLocationNotFound
|
|
}
|
|
|
|
return s.repo.DeleteLocation(ctx, locationID)
|
|
}
|
|
|
|
// ============================================
|
|
// BLOCKED DAYS MANAGEMENT
|
|
// ============================================
|
|
|
|
func (s *Service) ListBlockedDays(ctx context.Context, principal domain.Principal, from time.Time, to time.Time) ([]domain.BlockedDay, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return nil, ErrTenantMembership
|
|
}
|
|
|
|
records, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blockedDays := make([]domain.BlockedDay, len(records))
|
|
for i, rec := range records {
|
|
blockedDays[i] = domain.BlockedDay{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Date: rec.StartsAt,
|
|
Reason: rec.Reason,
|
|
Type: rec.Kind,
|
|
StaffID: rec.StaffID,
|
|
CreatedAt: rec.CreatedAt,
|
|
}
|
|
}
|
|
return blockedDays, nil
|
|
}
|
|
|
|
func (s *Service) CreateBlockedDay(ctx context.Context, principal domain.Principal, req domain.CreateBlockedDayRequest) (domain.BlockedDay, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.BlockedDay{}, ErrTenantMembership
|
|
}
|
|
|
|
date, err := time.Parse(time.RFC3339, req.Date)
|
|
if err != nil {
|
|
return domain.BlockedDay{}, ErrInvalidBooking
|
|
}
|
|
|
|
params := db.CreateBlockedDayParams{
|
|
TenantID: membership.Tenant.ID,
|
|
StaffID: req.StaffID,
|
|
StartsAt: date,
|
|
EndsAt: date.Add(24 * time.Hour),
|
|
Kind: req.Type,
|
|
Reason: req.Reason,
|
|
}
|
|
|
|
rec, err := s.repo.CreateBlockedDay(ctx, params)
|
|
if err != nil {
|
|
return domain.BlockedDay{}, err
|
|
}
|
|
|
|
return domain.BlockedDay{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Date: rec.StartsAt,
|
|
Reason: rec.Reason,
|
|
Type: rec.Kind,
|
|
StaffID: rec.StaffID,
|
|
CreatedAt: rec.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) UpdateBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string, req domain.UpdateBlockedDayRequest) (domain.BlockedDay, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.BlockedDay{}, ErrTenantMembership
|
|
}
|
|
|
|
// Verify blocked day belongs to tenant
|
|
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
|
if err != nil {
|
|
return domain.BlockedDay{}, err
|
|
}
|
|
|
|
found := false
|
|
for _, b := range bd {
|
|
if b.ID == blockedDayID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return domain.BlockedDay{}, ErrBlockedDayNotFound
|
|
}
|
|
|
|
params := db.UpdateBlockedDayParams{}
|
|
if req.Reason != "" {
|
|
params.Reason = &req.Reason
|
|
}
|
|
if req.Type != "" {
|
|
params.Kind = &req.Type
|
|
}
|
|
|
|
rec, err := s.repo.UpdateBlockedDay(ctx, blockedDayID, params)
|
|
if err != nil {
|
|
return domain.BlockedDay{}, err
|
|
}
|
|
|
|
return domain.BlockedDay{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Date: rec.StartsAt,
|
|
Reason: rec.Reason,
|
|
Type: rec.Kind,
|
|
StaffID: rec.StaffID,
|
|
CreatedAt: rec.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) DeleteBlockedDay(ctx context.Context, principal domain.Principal, blockedDayID string) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
|
|
// Verify blocked day belongs to tenant
|
|
bd, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, time.Time{}, time.Now().Add(365*24*time.Hour))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
found := false
|
|
for _, b := range bd {
|
|
if b.ID == blockedDayID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return ErrBlockedDayNotFound
|
|
}
|
|
|
|
return s.repo.DeleteBlockedDay(ctx, blockedDayID)
|
|
}
|
|
|
|
// ============================================
|
|
// CUSTOMER MANAGEMENT
|
|
// ============================================
|
|
|
|
func (s *Service) ListCustomers(ctx context.Context, principal domain.Principal, limit int, offset int) ([]domain.Customer, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return nil, ErrTenantMembership
|
|
}
|
|
|
|
records, err := s.repo.ListCustomersByTenant(ctx, membership.Tenant.ID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
customers := make([]domain.Customer, len(records))
|
|
for i, rec := range records {
|
|
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
|
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
|
|
|
customers[i] = domain.Customer{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Name: rec.Name,
|
|
Email: rec.Email,
|
|
Phone: rec.Phone,
|
|
Status: rec.Status,
|
|
BookingsCount: bookingsCount,
|
|
LastBookingAt: lastBooking,
|
|
CreatedAt: rec.CreatedAt,
|
|
Notes: "",
|
|
}
|
|
if rec.Notes != nil {
|
|
customers[i].Notes = *rec.Notes
|
|
}
|
|
}
|
|
return customers, nil
|
|
}
|
|
|
|
func (s *Service) CreateCustomer(ctx context.Context, principal domain.Principal, req domain.CreateCustomerRequest) (domain.Customer, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.Customer{}, ErrTenantMembership
|
|
}
|
|
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "active"
|
|
}
|
|
|
|
params := db.CreateCustomerParams{
|
|
TenantID: membership.Tenant.ID,
|
|
Name: req.Name,
|
|
Email: req.Email,
|
|
Phone: req.Phone,
|
|
Status: status,
|
|
Notes: nil,
|
|
}
|
|
|
|
rec, err := s.repo.CreateCustomer(ctx, params)
|
|
if err != nil {
|
|
return domain.Customer{}, err
|
|
}
|
|
|
|
return domain.Customer{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Name: rec.Name,
|
|
Email: rec.Email,
|
|
Phone: rec.Phone,
|
|
Status: rec.Status,
|
|
CreatedAt: rec.CreatedAt,
|
|
Notes: "",
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) UpdateCustomer(ctx context.Context, principal domain.Principal, customerID string, req domain.UpdateCustomerRequest) (domain.Customer, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.Customer{}, ErrTenantMembership
|
|
}
|
|
|
|
// Verify customer belongs to tenant
|
|
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
|
if err != nil {
|
|
return domain.Customer{}, ErrCustomerNotFound
|
|
}
|
|
if cust.TenantID != membership.Tenant.ID {
|
|
return domain.Customer{}, ErrCustomerNotFound
|
|
}
|
|
|
|
params := db.UpdateCustomerParams{}
|
|
if req.Name != "" {
|
|
params.Name = &req.Name
|
|
}
|
|
if req.Email != "" {
|
|
params.Email = &req.Email
|
|
}
|
|
if req.Phone != nil {
|
|
params.Phone = req.Phone
|
|
}
|
|
if req.Status != "" {
|
|
params.Status = &req.Status
|
|
}
|
|
if req.Notes != "" {
|
|
params.Notes = &req.Notes
|
|
}
|
|
|
|
rec, err := s.repo.UpdateCustomer(ctx, customerID, params)
|
|
if err != nil {
|
|
return domain.Customer{}, err
|
|
}
|
|
|
|
bookingsCount, _ := s.repo.GetCustomerBookingsCount(ctx, rec.ID)
|
|
lastBooking, _ := s.repo.GetCustomerLastBooking(ctx, rec.ID)
|
|
|
|
return domain.Customer{
|
|
ID: rec.ID,
|
|
TenantID: rec.TenantID,
|
|
Name: rec.Name,
|
|
Email: rec.Email,
|
|
Phone: rec.Phone,
|
|
Status: rec.Status,
|
|
BookingsCount: bookingsCount,
|
|
LastBookingAt: lastBooking,
|
|
CreatedAt: rec.CreatedAt,
|
|
Notes: "",
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) DeleteCustomer(ctx context.Context, principal domain.Principal, customerID string) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
|
|
cust, err := s.repo.GetCustomerByID(ctx, customerID)
|
|
if err != nil {
|
|
return ErrCustomerNotFound
|
|
}
|
|
if cust.TenantID != membership.Tenant.ID {
|
|
return ErrCustomerNotFound
|
|
}
|
|
|
|
return s.repo.DeleteCustomer(ctx, customerID)
|
|
}
|
|
|
|
// ============================================
|
|
// WORKING HOURS MANAGEMENT
|
|
// ============================================
|
|
|
|
func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Principal) ([]domain.WorkingHours, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return nil, ErrTenantMembership
|
|
}
|
|
|
|
records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hours := make([]domain.WorkingHours, len(records))
|
|
for i, rec := range records {
|
|
hours[i] = domain.WorkingHours{
|
|
DayOfWeek: rec.DayOfWeek,
|
|
Open: rec.StartsLocal,
|
|
Close: rec.EndsLocal,
|
|
IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "",
|
|
}
|
|
}
|
|
return hours, nil
|
|
}
|
|
|
|
func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Principal, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return ErrTenantMembership
|
|
}
|
|
|
|
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
|
|
}
|