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) UpdateClassTemplate(ctx context.Context, templateID string, 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) ListBookingsBySession(ctx context.Context, sessionID string) ([]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 CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) 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) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error UpdateBookingSession(ctx context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) 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) // Calendar feeds GetTenantByFeedToken(ctx context.Context, token string) (TenantRecord, error) GetStaffByFeedToken(ctx context.Context, token string) (StaffRecord, error) GetTenantFeedToken(ctx context.Context, tenantID string) (string, error) ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error) RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error) RotateStaffFeedToken(ctx context.Context, staffID string) (string, 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 StaffFeedTokenRecord struct { StaffID string DisplayName string FeedToken string } 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 CompletedCount int NewClientsCount 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 CustomerPhone string Reference string StartsAt time.Time Status string BookingStatus 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 } // UpdateBookingSession retargets a class booking to a different session and // carries the session's own time window onto the booking. func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) error { _, err := r.pool.Exec(ctx, ` UPDATE bookings SET class_session_id = $2, starts_at = $3, ends_at = $4, status = $5, updated_at = now() WHERE id = $1 `, bookingID, sessionID, startsAt, endsAt, status) 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 tenantFeedToken string staffFeedTokens map[string]string } 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: "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: ""}, }, tenantFeedToken: "demo-tenant-feed-token", staffFeedTokens: map[string]string{ staffID: "demo-staff-feed-token", }, } } 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 "owner@bookra.dev", 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) GetTenantByFeedToken(_ context.Context, token string) (TenantRecord, error) { if token != r.tenantFeedToken { return TenantRecord{}, pgx.ErrNoRows } return r.tenant, nil } func (r *MemoryRepository) GetStaffByFeedToken(_ context.Context, token string) (StaffRecord, error) { for staffID, feedToken := range r.staffFeedTokens { if feedToken != token { continue } for _, member := range r.staff { if member.ID == staffID { return member, nil } } } return StaffRecord{}, pgx.ErrNoRows } func (r *MemoryRepository) GetTenantFeedToken(_ context.Context, tenantID string) (string, error) { if tenantID != r.tenant.ID { return "", pgx.ErrNoRows } return r.tenantFeedToken, nil } func (r *MemoryRepository) ListStaffFeedTokens(_ context.Context, tenantID string) ([]StaffFeedTokenRecord, error) { if tenantID != r.tenant.ID { return nil, nil } records := make([]StaffFeedTokenRecord, 0, len(r.staff)) for _, member := range r.staff { token := r.staffFeedTokens[member.ID] if token == "" { token = strings.ReplaceAll(uuid.NewString(), "-", "") if r.staffFeedTokens == nil { r.staffFeedTokens = map[string]string{} } r.staffFeedTokens[member.ID] = token } records = append(records, StaffFeedTokenRecord{StaffID: member.ID, DisplayName: member.DisplayName, FeedToken: token}) } return records, nil } func (r *MemoryRepository) RotateTenantFeedToken(_ context.Context, tenantID string) (string, error) { if tenantID != r.tenant.ID { return "", pgx.ErrNoRows } r.tenantFeedToken = strings.ReplaceAll(uuid.NewString(), "-", "") return r.tenantFeedToken, nil } func (r *MemoryRepository) RotateStaffFeedToken(_ context.Context, staffID string) (string, error) { for _, member := range r.staff { if member.ID != staffID { continue } token := strings.ReplaceAll(uuid.NewString(), "-", "") if r.staffFeedTokens == nil { r.staffFeedTokens = map[string]string{} } r.staffFeedTokens[staffID] = token return token, 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) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) { for index := range r.classTemplates { if r.classTemplates[index].ID == templateID { r.classTemplates[index].Title = params.Title r.classTemplates[index].DurationMinutes = params.DurationMinutes r.classTemplates[index].Capacity = params.Capacity return r.classTemplates[index], nil } } return ClassTemplateRecord{}, pgx.ErrNoRows } 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) ListBookingsBySession(_ context.Context, sessionID string) ([]BookingRecord, error) { var out []BookingRecord for _, booking := range r.bookings { if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID { out = append(out, booking) } } sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) 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 := "customer@example.com" customerPhone := "" for _, booking := range r.bookings { if booking.ID == params.BookingID { reference = booking.Reference startsAt = booking.StartsAt customerName = booking.CustomerName customerEmail = booking.CustomerEmail customerPhone = booking.CustomerPhone 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, CustomerPhone: customerPhone, 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) { for _, booking := range r.bookings { if booking.ID == job.BookingID { job.BookingStatus = booking.Status job.CustomerPhone = booking.CustomerPhone break } } 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) CancelPendingReminderJobsForBooking(_ context.Context, bookingID string) error { for index := range r.reminderJobs { if r.reminderJobs[index].BookingID == bookingID && r.reminderJobs[index].Status == "pending" { r.reminderJobs[index].Status = "cancelled" } } return nil } func (r *MemoryRepository) DeletePendingReminderJobsForBooking(_ context.Context, bookingID string) error { kept := r.reminderJobs[:0] for _, job := range r.reminderJobs { if job.BookingID == bookingID && job.Status == "pending" { continue } kept = append(kept, job) } r.reminderJobs = kept return nil } 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 booking.EndsAt.Before(time.Now().UTC()) { metrics.CompletedCount++ } } } firstSeen := map[string]time.Time{} for _, booking := range r.bookings { if booking.TenantID != tenantID { continue } first, ok := firstSeen[booking.CustomerEmail] if !ok || booking.StartsAt.Before(first) { firstSeen[booking.CustomerEmail] = booking.StartsAt } } for _, first := range firstSeen { if !first.Before(startsAt) && first.Before(endsAt) { metrics.NewClientsCount++ } } 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) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) { var self *BookingRecord for index := range r.bookings { if r.bookings[index].ID == bookingID { self = &r.bookings[index] break } } if self == nil { return 0, pgx.ErrNoRows } position := 1 for _, booking := range r.bookings { if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID && booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) { position++ } } return position, nil } 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 } func (r *MemoryRepository) UpdateBookingSession(_ context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) error { for i, b := range r.bookings { if b.ID == bookingID { r.bookings[i].ClassSessionID = &sessionID r.bookings[i].StartsAt = startsAt r.bookings[i].EndsAt = endsAt r.bookings[i].Status = status 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 }