Files
Bookra/apps/backend/internal/db/repository.go
T
Tomas DvorakandDevin 668a54ae1d feat(bookings): dashboard cancellation and waitlist promotion
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
  tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
  when a class seat frees, on both dashboard and customer cancel paths;
  promoted customers get a confirmation email, waitlist ledger marked
  resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
  badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
  ResolveWaitlistEntry added to the repository surface

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 11:21:46 +02:00

2061 lines
63 KiB
Go

package db
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error)
GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error)
GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error)
EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error
CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error)
GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error)
IsAdminSubject(ctx context.Context, subject string) (bool, error)
GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error)
GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error)
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error)
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
DeleteClassSession(ctx context.Context, sessionID string) error
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
DeleteClassTemplate(ctx context.Context, templateID string) error
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error
GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error
UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error)
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
UpdateLastLogin(ctx context.Context, userID string) error
MarkEmailVerified(ctx context.Context, userID string) error
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
MarkMagicLinkUsed(ctx context.Context, token string) error
// Admin methods
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
GetPlatformStats(ctx context.Context) (PlatformStats, error)
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
UpdateUserRole(ctx context.Context, userID, role string) error
// Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error)
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
DeleteLocation(ctx context.Context, locationID string) error
// Service Management
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
DeleteService(ctx context.Context, serviceID string) error
// Staff Management
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
DeleteStaff(ctx context.Context, staffID string) error
// Blocked Days / Availability Exceptions
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error)
DeleteBlockedDay(ctx context.Context, blockedDayID string) error
// Customer Management
ListCustomersByTenant(ctx context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error)
GetCustomerByID(ctx context.Context, customerID string) (CustomerRecord, error)
GetCustomerByEmail(ctx context.Context, tenantID string, email string) (CustomerRecord, error)
CreateCustomer(ctx context.Context, params CreateCustomerParams) (CustomerRecord, error)
UpdateCustomer(ctx context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error)
DeleteCustomer(ctx context.Context, customerID string) error
GetCustomerBookingsCount(ctx context.Context, customerID string) (int, error)
GetCustomerLastBooking(ctx context.Context, customerID string) (*time.Time, error)
// Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
// Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error
// Email templates
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error)
UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error)
// SMS
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
}
type TenantRecord struct {
ID string
Slug string
Name string
Preset string
Locale string
Timezone string
PlanCode string
SubscriptionStatus string
BillingProvider string
BillingCustomerID *string
BillingSubscription *string
}
type UserRecord struct {
ID uuid.UUID
Email string
Name *string
PasswordHash *string
EmailVerified bool
Provider string
Role string
CreatedAt time.Time
LastLoginAt *time.Time
}
type MagicLinkRecord struct {
Token string
UserID uuid.UUID
Email string
Used bool
ExpiresAt time.Time
CreatedAt time.Time
}
type PlatformStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
}
type AdminAuditLogParams struct {
AdminUserID string
Action string
ResourceType string
ResourceID string
Details map[string]any
IPAddress string
UserAgent string
}
type TenantMembershipRecord struct {
Tenant TenantRecord
UserID string
Role string
}
type CreateTenantForUserParams struct {
Subject string
Name string
Slug string
Preset string
Locale string
Timezone string
BrandName string
SiteURL string
LogoURL string
PrimaryColor string
LocationName string
ServiceName string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
CancelWindowHours int
AvailabilityBlocks []AvailabilityBlockRecord
TeamInvites []TeamInviteRecord
}
type BrandProfileRecord struct {
TenantID string
Name string
SiteURL string
LogoURL string
PrimaryColor string
UmamiSiteID string
}
type UpdateBrandProfileParams struct {
Name *string
SiteURL *string
LogoURL *string
PrimaryColor *string
}
type AvailabilityBlockRecord struct {
DayOfWeek int
StartsLocal string
EndsLocal string
Busy bool
}
type TeamInviteRecord struct {
Email string
Role string
}
type ServiceRecord struct {
ID string
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
CreatedAt time.Time
}
type CreateServiceParams struct {
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
}
// Pointer fields distinguish "not sent" (keep current value) from an explicit
// zero value, which is meaningful for buffers and price.
type UpdateServiceParams struct {
Name *string
DurationMinutes *int
BufferBeforeMinutes *int
BufferAfterMinutes *int
PriceCents *int
}
type StaffRecord struct {
ID string
TenantID string
LocationID *string
DisplayName string
Active bool
CreatedAt time.Time
}
type CreateStaffParams struct {
TenantID string
LocationID *string
DisplayName string
Active bool
}
// LocationID tri-state: nil keeps the current assignment, a pointer to an
// empty string clears it, a pointer to an id reassigns it.
type UpdateStaffParams struct {
DisplayName *string
LocationID *string
Active *bool
}
type AvailabilityRuleRecord struct {
ID string
TenantID string
StaffID *string
DayOfWeek int
StartsLocal string
EndsLocal string
}
type ClassTemplateRecord struct {
ID string
TenantID string
Title string
DurationMinutes int
Capacity int
CreatedAt time.Time
}
type CreateClassTemplateParams struct {
TenantID string
Title string
DurationMinutes int
Capacity int
}
type ClassSessionRecord struct {
ID string
TenantID string
TemplateID string
LocationID *string
Title string
StartsAt time.Time
EndsAt time.Time
Capacity int32
}
type CreateClassSessionParams struct {
TenantID string
TemplateID string
LocationID *string
StartsAt time.Time
EndsAt time.Time
Capacity int32
}
type BookingRecord struct {
ID string
TenantID string
ServiceID *string
ClassSessionID *string
StaffID *string
LocationID *string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
CreatedAt time.Time
}
type CreateBookingParams struct {
TenantID string
ServiceID *string
ClassSessionID *string
StaffID *string
LocationID *string
BookingMode string
CustomerName string
CustomerEmail string
CustomerPhone string
StartsAt time.Time
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
}
type CreatedBooking struct {
ID string
Reference string
Status string
ManageToken string
}
type WaitlistEntryParams struct {
TenantID string
ClassSessionID string
CustomerName string
CustomerEmail string
Position int
}
type DashboardMetrics struct {
BookingsCount int
CancellationsCount int
UtilizationPercent int
}
type ReminderJobParams struct {
TenantID string
BookingID string
Channel string
ScheduledFor time.Time
}
type ReminderJobRecord struct {
ID string
TenantID string
TenantName string
Locale string
Timezone string
BookingID string
Channel string
ScheduledFor time.Time
CustomerName string
CustomerEmail string
Reference string
StartsAt time.Time
Status string
}
type NotificationDeliveryLogParams struct {
TenantID string
ReminderJobID string
Channel string
Provider string
Recipient string
Status string
ExternalID string
ErrorMessage string
}
type BillingSnapshotRecord struct {
TenantID string
BillingProvider string
BillingCustomerID string
BillingSubscriptionID string
Status string
PlanCode string
Currency string
PriceID string
CancelAtPeriodEnd bool
CurrentPeriodStart *time.Time
CurrentPeriodEnd *time.Time
PaymentMethodBrand string
PaymentMethodLast4 string
LastSyncedAt *time.Time
}
// Location / Zone Records
type LocationRecord struct {
ID string
TenantID string
Name string
Type string
Capacity int
Timezone string
CreatedAt time.Time
}
type CreateLocationParams struct {
TenantID string
Name string
Type string
Capacity int
Timezone string
}
type UpdateLocationParams struct {
Name *string
Type *string
Capacity *int
Timezone *string
}
// Blocked Day Records
type BlockedDayRecord struct {
ID string
TenantID string
StaffID *string
StartsAt time.Time
EndsAt time.Time
Kind string
Reason string
CreatedAt time.Time
}
type CreateBlockedDayParams struct {
TenantID string
StaffID *string
StartsAt time.Time
EndsAt time.Time
Kind string
Reason string
}
type UpdateBlockedDayParams struct {
StartsAt *time.Time
EndsAt *time.Time
Kind *string
Reason *string
}
// Customer Records
type CustomerRecord struct {
ID string
TenantID string
Name string
Email string
Phone *string
Status string
CreatedAt time.Time
Notes *string
}
type CreateCustomerParams struct {
TenantID string
Name string
Email string
Phone *string
Status string
Notes *string
}
type UpdateCustomerParams struct {
Name *string
Email *string
Phone *string
Status *string
Notes *string
}
// Working Hours Records
type WorkingHoursRecord struct {
TenantID string
StaffID *string
DayOfWeek int
StartsLocal string
EndsLocal string
}
type UpdateWorkingHoursParams struct {
StartsLocal *string
EndsLocal *string
IsOpen *bool
}
// Email Template Records
type EmailTemplateRecord struct {
ID string
TenantID string
Type string
Subject string
BodyHTML string
BodyText string
IsEnabled bool
UpdatedAt time.Time
}
// SMS Records
type TenantSMSSettingsRecord struct {
TenantID string
Enabled bool
SenderName string
MonthlyLimit int
StripeSubscriptionItemID string
}
type SMSUsageLogRecord struct {
ID string
TenantID string
RecipientPhone string
MessageBody string
ExternalMessageID string
ExternalRequestID string
Status string
CostCents int
SentAt time.Time
CreatedAt time.Time
}
type SMSUsageSummary struct {
MessageCount int
TotalCostCents int
}
type SMSMonthlyReportRecord struct {
ID string
TenantID string
YearMonth string
MessageCount int
TotalCostCents int
StripeInvoiceID string
InvoiceSentAt *time.Time
CreatedAt time.Time
}
type PGRepository struct {
pool *pgxpool.Pool
}
func NewRepository(pools *Pools, demoMode bool) Repository {
if demoMode {
return NewMemoryRepository()
}
if pools != nil && pools.App != nil {
return &PGRepository{pool: pools.App}
}
return NewMemoryRepository()
}
// ============================================
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
// ============================================
// ============================================
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListCustomersByTenant(ctx context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, email, phone, status, created_at, notes
FROM customers
WHERE tenant_id = $1
ORDER BY name
LIMIT $2 OFFSET $3
`, tenantID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var records []CustomerRecord
for rows.Next() {
var rec CustomerRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetCustomerByID(ctx context.Context, customerID string) (CustomerRecord, error) {
var rec CustomerRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, email, phone, status, created_at, notes
FROM customers
WHERE id = $1
`, customerID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
return rec, err
}
func (r *PGRepository) GetCustomerByEmail(ctx context.Context, tenantID string, email string) (CustomerRecord, error) {
var rec CustomerRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, email, phone, status, created_at, notes
FROM customers
WHERE tenant_id = $1 AND email = $2
`, tenantID, email).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
return rec, err
}
func (r *PGRepository) CreateCustomer(ctx context.Context, params CreateCustomerParams) (CustomerRecord, error) {
var rec CustomerRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO customers (tenant_id, name, email, phone, status, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, name, email, phone, status, created_at, notes
`, params.TenantID, params.Name, params.Email, params.Phone, params.Status, params.Notes).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
return rec, err
}
func (r *PGRepository) UpdateCustomer(ctx context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error) {
var rec CustomerRecord
err := r.pool.QueryRow(ctx, `
UPDATE customers
SET name = COALESCE($2, name),
email = COALESCE($3, email),
phone = COALESCE($4, phone),
status = COALESCE($5, status),
notes = COALESCE($6, notes)
WHERE id = $1
RETURNING id, tenant_id, name, email, phone, status, created_at, notes
`, customerID, params.Name, params.Email, params.Phone, params.Status, params.Notes).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
return rec, err
}
func (r *PGRepository) DeleteCustomer(ctx context.Context, customerID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, customerID)
return err
}
func (r *PGRepository) GetCustomerBookingsCount(ctx context.Context, customerID string) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings
WHERE customer_email = (SELECT email FROM customers WHERE id = $1)
`, customerID).Scan(&count)
return count, err
}
func (r *PGRepository) GetCustomerLastBooking(ctx context.Context, customerID string) (*time.Time, error) {
var lastTime *time.Time
err := r.pool.QueryRow(ctx, `
SELECT MAX(starts_at) FROM bookings
WHERE customer_email = (SELECT email FROM customers WHERE id = $1)
`, customerID).Scan(&lastTime)
return lastTime, err
}
// ============================================
// BOOKING MANAGEMENT METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) {
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE id = $1
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error {
_, err := r.pool.Exec(ctx, `
UPDATE bookings SET status = $2, updated_at = now() WHERE id = $1
`, bookingID, status)
return err
}
func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE bookings SET starts_at = $2, ends_at = $3, updated_at = now() WHERE id = $1
`, bookingID, startsAt, endsAt)
return err
}
// ============================================
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord
bookings []BookingRecord
waitlistSize int
reminderJobs []ReminderJobRecord
deliveryLogs []NotificationDeliveryLogParams
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
}
func NewMemoryRepository() *MemoryRepository {
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
now := time.Now().UTC()
return &MemoryRepository{
tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
membership: TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
UserID: "demo-owner",
Role: "owner",
},
brand: BrandProfileRecord{
TenantID: tenantID,
Name: "Studio Atelier",
SiteURL: "https://studio-atelier.example",
PrimaryColor: "#a65c3e",
},
services: []ServiceRecord{
{
ID: serviceID,
TenantID: tenantID,
Name: "Signature treatment",
DurationMinutes: 60,
BufferBeforeMinutes: 0,
BufferAfterMinutes: 15,
PriceCents: 120000,
CreatedAt: now,
},
},
staff: []StaffRecord{
{
ID: staffID,
TenantID: tenantID,
LocationID: &locationID,
DisplayName: "Demo Stylist",
Active: true,
CreatedAt: now,
},
},
rules: []AvailabilityRuleRecord{
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 1, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 2, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 3, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
},
classTemplates: []ClassTemplateRecord{
{ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now},
},
classSessions: []ClassSessionRecord{
{
ID: sessionID,
TenantID: tenantID,
TemplateID: templateID,
LocationID: &locationID,
Title: "Small group mobility class",
StartsAt: now.Add(48 * time.Hour),
EndsAt: now.Add(49 * time.Hour),
Capacity: 4,
},
},
bookings: []BookingRecord{},
reminderJobs: []ReminderJobRecord{},
deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID,
BillingProvider: "stripe",
BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "",
Status: "active",
PlanCode: "pro",
Currency: "czk",
PriceID: "",
},
recordedEvents: map[string]struct{}{},
locations: []LocationRecord{
{ID: locationID, TenantID: tenantID, Name: "Main Studio", Timezone: "Europe/Prague", CreatedAt: now},
},
blockedDays: []BlockedDayRecord{},
customers: []CustomerRecord{
{ID: uuid.NewString(), TenantID: tenantID, Name: "Alice Johnson", Email: "[email protected]", Phone: stringPtr("+420123456789"), Status: "vip", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Bob Smith", Email: "[email protected]", Status: "active", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Carol White", Email: "[email protected]", Status: "active", CreatedAt: now},
},
workingHours: []WorkingHoursRecord{
{TenantID: tenantID, DayOfWeek: 1, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 2, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 3, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 4, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 5, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 6, StartsLocal: "10:00", EndsLocal: "14:00"},
{TenantID: tenantID, DayOfWeek: 0, StartsLocal: "", EndsLocal: ""},
},
}
}
func (r *MemoryRepository) GetTenantBySlug(_ context.Context, slug string) (TenantRecord, error) {
if slug != r.tenant.Slug {
return TenantRecord{}, pgx.ErrNoRows
}
return r.tenant, nil
}
func (r *MemoryRepository) GetTenantByID(_ context.Context, tenantID string) (TenantRecord, error) {
if tenantID == r.tenant.ID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetTenantByBillingCustomerID(_ context.Context, customerID string) (TenantRecord, error) {
if r.billingSnapshot.BillingCustomerID == customerID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) EnsureUserIdentity(_ context.Context, subject string, email string, displayName string) error {
if strings.TrimSpace(subject) == "" {
return nil
}
r.membership.UserID = subject
_ = email
_ = displayName
return nil
}
func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tenantID := uuid.NewString()
r.tenant = TenantRecord{
ID: tenantID,
Slug: params.Slug,
Name: params.Name,
Preset: params.Preset,
Locale: params.Locale,
Timezone: params.Timezone,
PlanCode: "starter",
SubscriptionStatus: "trialing",
}
r.membership = TenantMembershipRecord{
Tenant: r.tenant,
UserID: params.Subject,
Role: "owner",
}
r.brand = BrandProfileRecord{
TenantID: tenantID,
Name: firstNonEmpty(params.BrandName, params.Name),
SiteURL: params.SiteURL,
LogoURL: params.LogoURL,
PrimaryColor: params.PrimaryColor,
}
serviceName := firstNonEmpty(params.ServiceName, "First appointment")
duration := params.DurationMinutes
if duration <= 0 {
duration = 60
}
r.services = []ServiceRecord{{
ID: uuid.NewString(),
TenantID: tenantID,
Name: serviceName,
DurationMinutes: duration,
BufferBeforeMinutes: maxInt(params.BufferBeforeMinutes, 0),
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0,
}}
r.staff = nil
r.rules = nil
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
blocks = defaultAvailabilityBlocks()
}
for _, block := range blocks {
if block.Busy {
continue
}
r.rules = append(r.rules, AvailabilityRuleRecord{
ID: uuid.NewString(),
TenantID: tenantID,
DayOfWeek: block.DayOfWeek,
StartsLocal: block.StartsLocal,
EndsLocal: block.EndsLocal,
})
}
r.classSessions = nil
r.bookings = nil
r.reminderJobs = nil
return r.membership, nil
}
func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
if subject == "" || subject == "demo-owner" {
return r.membership, nil
}
if subject == r.membership.UserID {
return r.membership, nil
}
return TenantMembershipRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) IsAdminSubject(_ context.Context, subject string) (bool, error) {
return subject == "demo-owner" || subject == r.membership.UserID, nil
}
func (r *MemoryRepository) GetTenantOwnerEmail(_ context.Context, tenantID string) (string, error) {
if tenantID != r.tenant.ID {
return "", pgx.ErrNoRows
}
return "[email protected]", nil
}
func (r *MemoryRepository) UpdateBrandProfile(_ context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if params.Name != nil {
r.brand.Name = *params.Name
}
if params.SiteURL != nil {
r.brand.SiteURL = *params.SiteURL
}
if params.LogoURL != nil {
r.brand.LogoURL = *params.LogoURL
}
if params.PrimaryColor != nil {
r.brand.PrimaryColor = *params.PrimaryColor
}
return r.brand, nil
}
func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if r.brand.Name == "" {
r.brand = BrandProfileRecord{TenantID: tenantID, Name: r.tenant.Name}
}
return r.brand, nil
}
func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID string) ([]ServiceRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]ServiceRecord(nil), r.services...), nil
}
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
for _, service := range r.services {
if service.ID == serviceID {
return service, nil
}
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
service := ServiceRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
DurationMinutes: params.DurationMinutes,
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
CreatedAt: time.Now().UTC(),
}
r.services = append(r.services, service)
return service, nil
}
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
if params.Name != nil {
service.Name = *params.Name
}
if params.DurationMinutes != nil {
service.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
service.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
service.PriceCents = *params.PriceCents
}
r.services[i] = service
return service, nil
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
r.services = append(r.services[:i], r.services[i+1:]...)
// Mirror ON DELETE SET NULL for bookings referencing the service.
for j, booking := range r.bookings {
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
booking.ServiceID = nil
r.bookings[j] = booking
}
}
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]StaffRecord(nil), r.staff...), nil
}
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
for _, member := range r.staff {
if member.ID == staffID {
return member, nil
}
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
member := StaffRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
LocationID: params.LocationID,
DisplayName: params.DisplayName,
Active: params.Active,
CreatedAt: time.Now().UTC(),
}
r.staff = append(r.staff, member)
return member, nil
}
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
if params.DisplayName != nil {
member.DisplayName = *params.DisplayName
}
if params.LocationID != nil {
locationID := strings.TrimSpace(*params.LocationID)
if locationID == "" {
member.LocationID = nil
} else {
member.LocationID = &locationID
}
}
if params.Active != nil {
member.Active = *params.Active
}
r.staff[i] = member
return member, nil
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
r.staff = append(r.staff[:i], r.staff[i+1:]...)
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
for j, booking := range r.bookings {
if booking.StaffID != nil && *booking.StaffID == staffID {
booking.StaffID = nil
r.bookings[j] = booking
}
}
var rules []AvailabilityRuleRecord
for _, rule := range r.rules {
if rule.StaffID == nil || *rule.StaffID != staffID {
rules = append(rules, rule)
}
}
r.rules = rules
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]AvailabilityRuleRecord(nil), r.rules...), nil
}
func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassSessionRecord
for _, session := range r.classSessions {
if session.StartsAt.Before(from) {
continue
}
out = append(out, session)
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) {
for _, session := range r.classSessions {
if session.ID == sessionID {
return session, nil
}
}
return ClassSessionRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
record := ClassSessionRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TemplateID: params.TemplateID,
LocationID: params.LocationID,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Capacity: params.Capacity,
}
for _, template := range r.classTemplates {
if template.ID == params.TemplateID {
record.Title = template.Title
break
}
}
r.classSessions = append(r.classSessions, record)
return record, nil
}
func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error {
for i, session := range r.classSessions {
if session.ID == sessionID {
r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...)
for j := range r.bookings {
if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID {
r.bookings[j].ClassSessionID = nil
}
}
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]ClassTemplateRecord(nil), r.classTemplates...), nil
}
func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) {
for _, template := range r.classTemplates {
if template.ID == templateID {
return template, nil
}
}
return ClassTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
record := ClassTemplateRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Title: params.Title,
DurationMinutes: params.DurationMinutes,
Capacity: params.Capacity,
CreatedAt: time.Now().UTC(),
}
r.classTemplates = append(r.classTemplates, record)
return record, nil
}
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
for i, template := range r.classTemplates {
if template.ID == templateID {
r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...)
// class_sessions.template_id has ON DELETE CASCADE
var remaining []ClassSessionRecord
for _, session := range r.classSessions {
if session.TemplateID != templateID {
remaining = append(remaining, session)
}
}
r.classSessions = remaining
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
var out []BookingRecord
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
if booking.StartsAt.Before(to) && booking.EndsAt.After(from) {
out = append(out, booking)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
return out, nil
}
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
ManageToken: params.ManageToken,
}
r.bookings = append(r.bookings, BookingRecord{
ID: created.ID,
TenantID: params.TenantID,
ServiceID: params.ServiceID,
ClassSessionID: params.ClassSessionID,
StaffID: params.StaffID,
LocationID: params.LocationID,
CustomerName: params.CustomerName,
CustomerEmail: params.CustomerEmail,
CustomerPhone: params.CustomerPhone,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Status: params.Status,
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
CreatedAt: time.Now().UTC(),
})
return created, nil
}
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
r.waitlistSize++
return nil
}
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
if r.waitlistSize > 0 {
r.waitlistSize--
}
return nil
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID
startsAt := time.Now().UTC()
customerName := "Booking Customer"
customerEmail := "[email protected]"
for _, booking := range r.bookings {
if booking.ID == params.BookingID {
reference = booking.Reference
startsAt = booking.StartsAt
customerName = booking.CustomerName
customerEmail = booking.CustomerEmail
break
}
}
r.reminderJobs = append(r.reminderJobs, ReminderJobRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TenantName: r.tenant.Name,
Locale: r.tenant.Locale,
Timezone: r.tenant.Timezone,
BookingID: params.BookingID,
Channel: params.Channel,
ScheduledFor: params.ScheduledFor,
CustomerName: customerName,
CustomerEmail: customerEmail,
Reference: reference,
StartsAt: startsAt,
Status: "pending",
})
return nil
}
func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
var out []ReminderJobRecord
for _, job := range r.reminderJobs {
if job.Status == "pending" && !job.ScheduledFor.After(dueBefore) {
out = append(out, job)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ScheduledFor.Before(out[j].ScheduledFor) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *MemoryRepository) MarkReminderJobDispatched(_ context.Context, reminderJobID string, status string, _ time.Time) error {
for index := range r.reminderJobs {
if r.reminderJobs[index].ID == reminderJobID {
r.reminderJobs[index].Status = status
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) CreateNotificationDeliveryLog(_ context.Context, params NotificationDeliveryLogParams) error {
r.deliveryLogs = append(r.deliveryLogs, params)
return nil
}
func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
metrics := DashboardMetrics{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) {
continue
}
metrics.BookingsCount++
if booking.Status == "cancelled" {
metrics.CancellationsCount++
}
if booking.Status == "confirmed" {
metrics.UtilizationPercent++
}
}
if metrics.BookingsCount == 0 {
metrics.UtilizationPercent = 0
} else {
metrics.UtilizationPercent = int(float64(metrics.UtilizationPercent) / float64(metrics.BookingsCount) * 100)
}
return metrics, nil
}
func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) {
if tenantID != r.tenant.ID {
return BillingSnapshotRecord{}, pgx.ErrNoRows
}
return r.billingSnapshot, nil
}
func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params BillingSnapshotRecord) error {
r.billingSnapshot = params
return nil
}
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, provider string, customerID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.BillingProvider = provider
r.tenant.BillingCustomerID = &customerID
r.membership.Tenant.BillingProvider = provider
r.membership.Tenant.BillingCustomerID = &customerID
r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingCustomerID = customerID
return nil
}
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.PlanCode = planCode
r.tenant.SubscriptionStatus = subscriptionStatus
r.tenant.BillingProvider = provider
r.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.membership.Tenant.BillingProvider = provider
r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingSubscriptionID = subscriptionID
return nil
}
func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) {
key := firstNonEmpty(provider, "stripe") + ":" + eventID
if _, exists := r.recordedEvents[key]; exists {
return false, nil
}
if len(payload) > 0 {
var raw json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
return false, err
}
}
r.recordedEvents[key] = struct{}{}
return true, nil
}
// ============================================
// LOCATION / ZONE METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListLocationsByTenant(_ context.Context, tenantID string) ([]LocationRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.locations, nil
}
func (r *MemoryRepository) GetLocationByID(_ context.Context, locationID string) (LocationRecord, error) {
for _, loc := range r.locations {
if loc.ID == locationID {
return loc, nil
}
}
return LocationRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocationParams) (LocationRecord, error) {
loc := LocationRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Type: params.Type,
Capacity: params.Capacity,
Timezone: params.Timezone,
CreatedAt: time.Now(),
}
r.locations = append(r.locations, loc)
return loc, nil
}
func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
for i, loc := range r.locations {
if loc.ID == locationID {
if params.Name != nil {
r.locations[i].Name = *params.Name
}
if params.Type != nil {
r.locations[i].Type = *params.Type
}
if params.Capacity != nil {
r.locations[i].Capacity = *params.Capacity
}
if params.Timezone != nil {
r.locations[i].Timezone = *params.Timezone
}
return r.locations[i], nil
}
}
return LocationRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteLocation(_ context.Context, locationID string) error {
for i, loc := range r.locations {
if loc.ID == locationID {
r.locations = append(r.locations[:i], r.locations[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// BLOCKED DAYS METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListBlockedDaysByTenant(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
var out []BlockedDayRecord
for _, bd := range r.blockedDays {
if bd.TenantID == tenantID && !bd.StartsAt.After(to) && !bd.EndsAt.Before(from) {
out = append(out, bd)
}
}
return out, nil
}
func (r *MemoryRepository) CreateBlockedDay(_ context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
bd := BlockedDayRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
StaffID: params.StaffID,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Kind: params.Kind,
Reason: params.Reason,
CreatedAt: time.Now(),
}
r.blockedDays = append(r.blockedDays, bd)
return bd, nil
}
func (r *MemoryRepository) UpdateBlockedDay(_ context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
for i, bd := range r.blockedDays {
if bd.ID == blockedDayID {
if params.StartsAt != nil {
r.blockedDays[i].StartsAt = *params.StartsAt
}
if params.EndsAt != nil {
r.blockedDays[i].EndsAt = *params.EndsAt
}
if params.Kind != nil {
r.blockedDays[i].Kind = *params.Kind
}
if params.Reason != nil {
r.blockedDays[i].Reason = *params.Reason
}
return r.blockedDays[i], nil
}
}
return BlockedDayRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteBlockedDay(_ context.Context, blockedDayID string) error {
for i, bd := range r.blockedDays {
if bd.ID == blockedDayID {
r.blockedDays = append(r.blockedDays[:i], r.blockedDays[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// CUSTOMER METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListCustomersByTenant(_ context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
start := offset
if start > len(r.customers) {
return []CustomerRecord{}, nil
}
end := start + limit
if end > len(r.customers) {
end = len(r.customers)
}
return r.customers[start:end], nil
}
func (r *MemoryRepository) GetCustomerByID(_ context.Context, customerID string) (CustomerRecord, error) {
for _, c := range r.customers {
if c.ID == customerID {
return c, nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetCustomerByEmail(_ context.Context, tenantID string, email string) (CustomerRecord, error) {
for _, c := range r.customers {
if c.TenantID == tenantID && c.Email == email {
return c, nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateCustomer(_ context.Context, params CreateCustomerParams) (CustomerRecord, error) {
c := CustomerRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Email: params.Email,
Phone: params.Phone,
Status: params.Status,
CreatedAt: time.Now(),
Notes: params.Notes,
}
r.customers = append(r.customers, c)
return c, nil
}
func (r *MemoryRepository) UpdateCustomer(_ context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error) {
for i, c := range r.customers {
if c.ID == customerID {
if params.Name != nil {
r.customers[i].Name = *params.Name
}
if params.Email != nil {
r.customers[i].Email = *params.Email
}
if params.Phone != nil {
r.customers[i].Phone = params.Phone
}
if params.Status != nil {
r.customers[i].Status = *params.Status
}
if params.Notes != nil {
r.customers[i].Notes = params.Notes
}
return r.customers[i], nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteCustomer(_ context.Context, customerID string) error {
for i, c := range r.customers {
if c.ID == customerID {
r.customers = append(r.customers[:i], r.customers[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) GetCustomerBookingsCount(_ context.Context, customerID string) (int, error) {
count := 0
for _, b := range r.bookings {
for _, c := range r.customers {
if c.ID == customerID && b.CustomerEmail == c.Email {
count++
}
}
}
return count, nil
}
func (r *MemoryRepository) GetCustomerLastBooking(_ context.Context, customerID string) (*time.Time, error) {
var lastTime *time.Time
var customerEmail string
for _, c := range r.customers {
if c.ID == customerID {
customerEmail = c.Email
break
}
}
if customerEmail == "" {
return nil, nil
}
for _, b := range r.bookings {
if b.CustomerEmail == customerEmail {
if lastTime == nil || b.StartsAt.After(*lastTime) {
lastTime = &b.StartsAt
}
}
}
return lastTime, nil
}
// ============================================
// BOOKING MANAGEMENT METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.Reference == reference {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.ID == bookingID {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings {
if b.ID == bookingID {
r.bookings[i].Status = status
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error {
for i, b := range r.bookings {
if b.ID == bookingID {
r.bookings[i].StartsAt = startsAt
r.bookings[i].EndsAt = endsAt
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// EMAIL TEMPLATE METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListEmailTemplates(_ context.Context, tenantID string) ([]EmailTemplateRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.emailTemplates, nil
}
func (r *MemoryRepository) GetEmailTemplate(_ context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) {
for _, t := range r.emailTemplates {
if t.TenantID == tenantID && t.Type == templateType {
return t, nil
}
}
return EmailTemplateRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) {
for i, t := range r.emailTemplates {
if t.TenantID == params.TenantID && t.Type == params.Type {
r.emailTemplates[i].Subject = params.Subject
r.emailTemplates[i].BodyHTML = params.BodyHTML
r.emailTemplates[i].BodyText = params.BodyText
r.emailTemplates[i].IsEnabled = params.IsEnabled
r.emailTemplates[i].UpdatedAt = time.Now()
return r.emailTemplates[i], nil
}
}
rec := EmailTemplateRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Type: params.Type,
Subject: params.Subject,
BodyHTML: params.BodyHTML,
BodyText: params.BodyText,
IsEnabled: params.IsEnabled,
UpdatedAt: time.Now(),
}
r.emailTemplates = append(r.emailTemplates, rec)
return rec, nil
}
// ============================================
// WORKING HOURS METHODS - MEMORY REPOSITORY
// ============================================
// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated
// working-hours store, and staff-scoped hours from the availability rules that
// slot generation consumes — mirroring PostgreSQL where both live in
// availability_rules.
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
var out []WorkingHoursRecord
if staffID == nil {
for _, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.StaffID == nil {
out = append(out, wh)
}
}
return out, nil
}
for _, rule := range r.rules {
if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID {
out = append(out, WorkingHoursRecord{
TenantID: rule.TenantID,
StaffID: rule.StaffID,
DayOfWeek: rule.DayOfWeek,
StartsLocal: rule.StartsLocal,
EndsLocal: rule.EndsLocal,
})
}
}
return out, nil
}
func staffIDPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
if staffID != nil {
return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params)
}
if params.IsOpen != nil && !*params.IsOpen {
filtered := r.workingHours[:0]
for _, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
continue
}
filtered = append(filtered, wh)
}
r.workingHours = filtered
return nil
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
for i, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
r.workingHours[i].StartsLocal = *params.StartsLocal
r.workingHours[i].EndsLocal = *params.EndsLocal
return nil
}
}
r.workingHours = append(r.workingHours, WorkingHoursRecord{
TenantID: tenantID,
DayOfWeek: dayOfWeek,
StartsLocal: *params.StartsLocal,
EndsLocal: *params.EndsLocal,
})
return nil
}
// updateStaffWorkingHours replaces the staff-scoped availability rule for a
// day so edits actually change generated slots, matching PostgreSQL semantics.
func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
filtered := r.rules[:0]
for _, rule := range r.rules {
if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID {
continue
}
filtered = append(filtered, rule)
}
r.rules = filtered
if params.IsOpen != nil && !*params.IsOpen {
return nil
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
r.rules = append(r.rules, AvailabilityRuleRecord{
ID: uuid.NewString(),
TenantID: tenantID,
StaffID: staffID,
DayOfWeek: dayOfWeek,
StartsLocal: normalizeLocalTime(*params.StartsLocal),
EndsLocal: normalizeLocalTime(*params.EndsLocal),
})
return nil
}
func normalizeLocalTime(value string) string {
if len(value) == 5 {
return value + ":00"
}
return value
}
// Auth methods for MemoryRepository
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
}
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
return nil
}
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
return nil, nil
}
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
return nil
}
// Admin methods for MemoryRepository
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
return []TenantRecord{r.tenant}, 1, nil
}
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
return []UserRecord{}, 0, nil
}
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
}
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
return nil
}
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
return nil
}
func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
}
func stringPtr(value string) *string {
return &value
}
func maxInt(left int, right int) int {
if left > right {
return left
}
return right
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func defaultAvailabilityBlocks() []AvailabilityBlockRecord {
blocks := make([]AvailabilityBlockRecord, 0, 5)
for day := 1; day <= 5; day++ {
blocks = append(blocks, AvailabilityBlockRecord{
DayOfWeek: day,
StartsLocal: "09:00:00",
EndsLocal: "17:00:00",
})
}
return blocks
}