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) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, 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) 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 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, customerID string) error UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) 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 // 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) 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) ([]WorkingHoursRecord, error) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) 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 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 } type AvailabilityRuleRecord struct { ID string TenantID string StaffID *string DayOfWeek int StartsLocal string EndsLocal string } type ClassSessionRecord struct { ID string TenantID string TemplateID string LocationID *string Title 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 } 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 Notes string } type CreatedBooking struct { ID string Reference string Status 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 Timezone string CreatedAt time.Time } type CreateLocationParams struct { TenantID string Name string Timezone string } type UpdateLocationParams struct { Name *string 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 } // 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 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) 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 classSessions []ClassSessionRecord bookings []BookingRecord waitlistSize int reminderJobs []ReminderJobRecord deliveryLogs []NotificationDeliveryLogParams billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{} locations []LocationRecord blockedDays []BlockedDayRecord customers []CustomerRecord workingHours []WorkingHoursRecord smsSettings TenantSMSSettingsRecord smsLogs []SMSUsageLogRecord smsReports []SMSMonthlyReportRecord } 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: "paddle", 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: "paddle", 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, }, }, 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"}, }, 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: "paddle", 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: "alice@example.com", Phone: stringPtr("+420123456789"), Status: "vip", CreatedAt: now}, {ID: uuid.NewString(), TenantID: tenantID, Name: "Bob Smith", Email: "bob@example.com", Status: "active", CreatedAt: now}, {ID: uuid.NewString(), TenantID: tenantID, Name: "Carol White", Email: "carol@example.com", 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.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) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) { if userID == "" || userID == "demo-owner" { return r.membership, nil } if userID == r.membership.UserID { return r.membership, nil } return TenantMembershipRecord{}, pgx.ErrNoRows } 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) 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) 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, } 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, StartsAt: params.StartsAt, EndsAt: params.EndsAt, Status: params.Status, Reference: params.Reference, }) return created, nil } func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error { r.waitlistSize++ return nil } func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error { reference := params.BookingID startsAt := time.Now().UTC() customerName := "Booking Customer" customerEmail := "customer@example.com" 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, customerID string) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } r.tenant.BillingProvider = "paddle" r.tenant.BillingCustomerID = &customerID r.membership.Tenant.BillingProvider = "paddle" r.membership.Tenant.BillingCustomerID = &customerID r.billingSnapshot.BillingProvider = "paddle" r.billingSnapshot.BillingCustomerID = customerID return nil } func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID 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 = "paddle" r.membership.Tenant.PlanCode = planCode r.membership.Tenant.SubscriptionStatus = subscriptionStatus r.membership.Tenant.BillingProvider = "paddle" r.billingSnapshot.PlanCode = planCode r.billingSnapshot.Status = subscriptionStatus r.billingSnapshot.BillingProvider = "paddle" 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, "paddle") + ":" + 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, 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.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) 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 } // ============================================ // WORKING HOURS METHODS - MEMORY REPOSITORY // ============================================ func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) { if tenantID != r.tenant.ID { return nil, pgx.ErrNoRows } return r.workingHours, nil } func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } for i, wh := range r.workingHours { if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek { if params.StartsLocal != nil { r.workingHours[i].StartsLocal = *params.StartsLocal } if params.EndsLocal != nil { r.workingHours[i].EndsLocal = *params.EndsLocal } return nil } } return pgx.ErrNoRows } // 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 }