Files
Bookra/apps/backend/internal/db/repository.go
T
Tomas DvorakandDevin e680e0c2b8 feat: scheduled report digests + per-session capacity (M33)
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

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

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

2729 lines
86 KiB
Go

package db
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"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
// UpdateClassSessionCapacity sets the session's seat count; the caller
// checks tenancy and the booked-seats floor.
UpdateClassSessionCapacity(ctx context.Context, sessionID string, capacity int) (ClassSessionRecord, 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
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error)
UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error)
DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error
ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error)
RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) 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)
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)
// Activity feed
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error)
// GetNotificationCursor returns nil when the user has no cursor yet.
GetNotificationCursor(ctx context.Context, tenantID string, userSubject string) (*time.Time, error)
SetNotificationCursor(ctx context.Context, tenantID string, userSubject string, readAt time.Time) error
// Staff metrics
GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, 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
// IntakeFields is the raw JSON list of declared structured intake fields;
// the catalog service parses and validates it into domain.IntakeField.
IntakeFields string
CreatedAt time.Time
}
type CreateServiceParams struct {
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
IntakeFields string
}
// 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
IntakeFields *string
}
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
}
// StaffMetricsRecord is a per-staff aggregation of appointment bookings —
// class bookings carry no staff_id and are excluded by design.
type StaffMetricsRecord struct {
StaffID string
BookingsCount int
CancellationsCount int
CompletedCount int
BookedMinutes int
RevenueCents int64
}
// ActivityEventRecord / ActivityEventParams back the per-tenant audit feed.
type ActivityEventRecord struct {
ID string
TenantID string
ActorKind string
ActorName string
Action string
EntityType string
EntityID *string
Summary string
CreatedAt time.Time
}
type ActivityEventParams struct {
TenantID string
ActorKind string
ActorSubject string
ActorName string
Action string
EntityType string
EntityID *string
Summary 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
ScheduleID *string
}
type CreateClassSessionParams struct {
TenantID string
TemplateID string
LocationID *string
StartsAt time.Time
EndsAt time.Time
Capacity int32
ScheduleID *string
}
// ClassScheduleRecord is a weekly recurring rule that materializes concrete
// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0).
type ClassScheduleRecord struct {
ID string
TenantID string
TemplateID string
LocationID *string
DayOfWeek int
StartsLocal string
Active bool
CreatedAt time.Time
}
type CreateClassScheduleParams struct {
TenantID string
TemplateID string
LocationID *string
DayOfWeek int
StartsLocal string
}
// UpdateClassScheduleParams carries optional rule fields; nil means keep.
// LocationID nil keeps the location, a pointer to "" clears it.
type UpdateClassScheduleParams struct {
DayOfWeek *int
StartsLocal *string
LocationID *string
Active *bool
}
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
// Intake is the raw JSON object of structured intake answers.
Intake 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
Intake string
}
type CreatedBooking struct {
ID string
Reference string
Status string
ManageToken string
}
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 {
repo := NewMemoryRepository()
repo.seedDemoBookings()
return repo
}
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, ''), COALESCE(intake::text, '{}'), 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.Intake, &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, ''), COALESCE(intake::text, '{}'), 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.Intake, &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
classSchedules []ClassScheduleRecord
bookings []BookingRecord
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
activityEvents []ActivityEventRecord
notificationCursors map[string]time.Time
}
func NewMemoryRepository() *MemoryRepository {
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
now := time.Now().UTC()
return &MemoryRepository{
tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
membership: TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
UserID: "demo-owner",
Role: "owner",
},
brand: BrandProfileRecord{
TenantID: tenantID,
Name: "Studio Atelier",
SiteURL: "https://studio-atelier.example",
PrimaryColor: "#a65c3e",
},
services: []ServiceRecord{
{
ID: serviceID,
TenantID: tenantID,
Name: "Signature treatment",
DurationMinutes: 60,
BufferBeforeMinutes: 0,
BufferAfterMinutes: 15,
PriceCents: 120000,
CreatedAt: now,
},
},
staff: []StaffRecord{
{
ID: staffID,
TenantID: tenantID,
LocationID: &locationID,
DisplayName: "Demo Stylist",
Active: true,
CreatedAt: now,
},
},
rules: []AvailabilityRuleRecord{
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 1, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 2, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 3, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
},
classTemplates: []ClassTemplateRecord{
{ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now},
},
classSessions: []ClassSessionRecord{
{
ID: sessionID,
TenantID: tenantID,
TemplateID: templateID,
LocationID: &locationID,
Title: "Small group mobility class",
StartsAt: now.Add(48 * time.Hour),
EndsAt: now.Add(49 * time.Hour),
Capacity: 4,
},
},
bookings: []BookingRecord{},
reminderJobs: []ReminderJobRecord{},
deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID,
BillingProvider: "stripe",
BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "",
Status: "active",
PlanCode: "pro",
Currency: "czk",
PriceID: "",
},
recordedEvents: map[string]struct{}{},
locations: []LocationRecord{
{ID: locationID, TenantID: tenantID, Name: "Main Studio", Timezone: "Europe/Prague", CreatedAt: now},
},
blockedDays: []BlockedDayRecord{},
customers: []CustomerRecord{
{ID: uuid.NewString(), TenantID: tenantID, Name: "Alice Johnson", Email: "[email protected]", Phone: stringPtr("+420123456789"), Status: "vip", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Bob Smith", Email: "[email protected]", Status: "active", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Carol White", Email: "[email protected]", Status: "active", CreatedAt: now},
},
workingHours: []WorkingHoursRecord{
{TenantID: tenantID, DayOfWeek: 1, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 2, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 3, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 4, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 5, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 6, StartsLocal: "10:00", EndsLocal: "14:00"},
{TenantID: tenantID, DayOfWeek: 0, StartsLocal: "", EndsLocal: ""},
},
tenantFeedToken: "demo-tenant-feed-token",
staffFeedTokens: map[string]string{
staffID: "demo-staff-feed-token",
},
notificationCursors: map[string]time.Time{},
activityEvents: []ActivityEventRecord{
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "staff", ActorName: "Demo Stylist",
Action: "booking.cancelled", EntityType: "booking",
Summary: "Carol White — Consultation",
CreatedAt: now.Add(-50 * time.Hour),
},
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "system",
Action: "booking.promoted", EntityType: "booking",
Summary: "Bob Smith — Small group mobility class",
CreatedAt: now.Add(-26 * time.Hour),
},
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "customer", ActorName: "Alice Johnson",
Action: "booking.created", EntityType: "booking",
Summary: "Alice Johnson — Relaxation massage",
CreatedAt: now.Add(-3 * time.Hour),
},
},
}
}
func (r *MemoryRepository) GetTenantBySlug(_ context.Context, slug string) (TenantRecord, error) {
if slug != r.tenant.Slug {
return TenantRecord{}, pgx.ErrNoRows
}
return r.tenant, nil
}
func (r *MemoryRepository) GetTenantByID(_ context.Context, tenantID string) (TenantRecord, error) {
if tenantID == r.tenant.ID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetTenantByBillingCustomerID(_ context.Context, customerID string) (TenantRecord, error) {
if r.billingSnapshot.BillingCustomerID == customerID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) EnsureUserIdentity(_ context.Context, subject string, email string, displayName string) error {
if strings.TrimSpace(subject) == "" {
return nil
}
r.membership.UserID = subject
_ = email
_ = displayName
return nil
}
func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tenantID := uuid.NewString()
r.tenant = TenantRecord{
ID: tenantID,
Slug: params.Slug,
Name: params.Name,
Preset: params.Preset,
Locale: params.Locale,
Timezone: params.Timezone,
PlanCode: "starter",
SubscriptionStatus: "trialing",
}
r.membership = TenantMembershipRecord{
Tenant: r.tenant,
UserID: params.Subject,
Role: "owner",
}
r.brand = BrandProfileRecord{
TenantID: tenantID,
Name: firstNonEmpty(params.BrandName, params.Name),
SiteURL: params.SiteURL,
LogoURL: params.LogoURL,
PrimaryColor: params.PrimaryColor,
}
serviceName := firstNonEmpty(params.ServiceName, "First appointment")
duration := params.DurationMinutes
if duration <= 0 {
duration = 60
}
r.services = []ServiceRecord{{
ID: uuid.NewString(),
TenantID: tenantID,
Name: serviceName,
DurationMinutes: duration,
BufferBeforeMinutes: maxInt(params.BufferBeforeMinutes, 0),
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0,
}}
r.staff = nil
r.rules = nil
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
blocks = defaultAvailabilityBlocks()
}
for _, block := range blocks {
if block.Busy {
continue
}
r.rules = append(r.rules, AvailabilityRuleRecord{
ID: uuid.NewString(),
TenantID: tenantID,
DayOfWeek: block.DayOfWeek,
StartsLocal: block.StartsLocal,
EndsLocal: block.EndsLocal,
})
}
r.classSessions = nil
r.bookings = nil
r.reminderJobs = nil
return r.membership, nil
}
func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
if subject == "" || subject == "demo-owner" {
return r.membership, nil
}
if subject == r.membership.UserID {
return r.membership, nil
}
return TenantMembershipRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) IsAdminSubject(_ context.Context, subject string) (bool, error) {
return subject == "demo-owner" || subject == r.membership.UserID, nil
}
func (r *MemoryRepository) GetTenantOwnerEmail(_ context.Context, tenantID string) (string, error) {
if tenantID != r.tenant.ID {
return "", pgx.ErrNoRows
}
return "[email protected]", nil
}
func (r *MemoryRepository) UpdateBrandProfile(_ context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if params.Name != nil {
r.brand.Name = *params.Name
}
if params.SiteURL != nil {
r.brand.SiteURL = *params.SiteURL
}
if params.LogoURL != nil {
r.brand.LogoURL = *params.LogoURL
}
if params.PrimaryColor != nil {
r.brand.PrimaryColor = *params.PrimaryColor
}
return r.brand, nil
}
func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if r.brand.Name == "" {
r.brand = BrandProfileRecord{TenantID: tenantID, Name: r.tenant.Name}
}
return r.brand, nil
}
func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID string) ([]ServiceRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]ServiceRecord(nil), r.services...), nil
}
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
for _, service := range r.services {
if service.ID == serviceID {
return service, nil
}
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
service := ServiceRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
DurationMinutes: params.DurationMinutes,
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
IntakeFields: params.IntakeFields,
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
}
if params.IntakeFields != nil {
service.IntakeFields = *params.IntakeFields
}
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) AppendActivityEvent(_ context.Context, params ActivityEventParams) error {
if params.TenantID != r.tenant.ID {
return nil
}
r.activityEvents = append(r.activityEvents, ActivityEventRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
ActorKind: params.ActorKind,
ActorName: params.ActorName,
Action: params.Action,
EntityType: params.EntityType,
EntityID: params.EntityID,
Summary: params.Summary,
CreatedAt: time.Now().UTC(),
})
return nil
}
func (r *MemoryRepository) ListActivityEvents(_ context.Context, tenantID string, limit int) ([]ActivityEventRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var events []ActivityEventRecord
for i := len(r.activityEvents) - 1; i >= 0 && len(events) < limit; i-- {
events = append(events, r.activityEvents[i])
}
return events, nil
}
func (r *MemoryRepository) GetNotificationCursor(_ context.Context, tenantID string, userSubject string) (*time.Time, error) {
readAt, ok := r.notificationCursors[tenantID+"/"+userSubject]
if !ok {
return nil, nil
}
return &readAt, nil
}
func (r *MemoryRepository) SetNotificationCursor(_ context.Context, tenantID string, userSubject string, readAt time.Time) error {
r.notificationCursors[tenantID+"/"+userSubject] = readAt
return 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) 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,
ScheduleID: params.ScheduleID,
}
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) UpdateClassSessionCapacity(_ context.Context, sessionID string, capacity int) (ClassSessionRecord, error) {
for i := range r.classSessions {
if r.classSessions[i].ID == sessionID {
r.classSessions[i].Capacity = int32(capacity)
return r.classSessions[i], nil
}
}
return ClassSessionRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassScheduleRecord
for _, schedule := range r.classSchedules {
if schedule.TenantID == tenantID {
out = append(out, schedule)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].DayOfWeek != out[j].DayOfWeek {
return out[i].DayOfWeek < out[j].DayOfWeek
}
return out[i].StartsLocal < out[j].StartsLocal
})
return out, nil
}
func (r *MemoryRepository) CreateClassSchedule(_ context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) {
record := ClassScheduleRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TemplateID: params.TemplateID,
LocationID: params.LocationID,
DayOfWeek: params.DayOfWeek,
StartsLocal: params.StartsLocal,
Active: true,
CreatedAt: time.Now().UTC(),
}
r.classSchedules = append(r.classSchedules, record)
return record, nil
}
func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID string, scheduleID string) error {
for i, schedule := range r.classSchedules {
if schedule.ID == scheduleID && schedule.TenantID == tenantID {
r.classSchedules = append(r.classSchedules[:i], r.classSchedules[i+1:]...)
return nil
}
}
return fmt.Errorf("class schedule not found")
}
// UpdateClassSchedule mirrors the PG implementation: nil fields are kept,
// a LocationID pointer to "" clears the location.
func (r *MemoryRepository) UpdateClassSchedule(_ context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
for i, schedule := range r.classSchedules {
if schedule.ID != scheduleID || schedule.TenantID != tenantID {
continue
}
if params.DayOfWeek != nil {
r.classSchedules[i].DayOfWeek = *params.DayOfWeek
}
if params.StartsLocal != nil {
r.classSchedules[i].StartsLocal = normalizeLocalTime(*params.StartsLocal)
}
if params.LocationID != nil {
if *params.LocationID == "" {
r.classSchedules[i].LocationID = nil
} else {
loc := *params.LocationID
r.classSchedules[i].LocationID = &loc
}
}
if params.Active != nil {
r.classSchedules[i].Active = *params.Active
}
return r.classSchedules[i], nil
}
return ClassScheduleRecord{}, fmt.Errorf("class schedule not found")
}
// ListClassSessionsBySchedule mirrors the PG implementation.
func (r *MemoryRepository) ListClassSessionsBySchedule(_ context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassSessionRecord
for _, session := range r.classSessions {
if session.ScheduleID != nil && *session.ScheduleID == scheduleID && session.StartsAt.After(after) {
out = append(out, session)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
return out, nil
}
// RetimeFutureScheduleSessions mirrors the PG implementation: future sessions
// of the rule keep their local date but move to the new start time and
// template duration, and live bookings on them follow.
func (r *MemoryRepository) RetimeFutureScheduleSessions(_ context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
if tenantID != r.tenant.ID {
return nil
}
loc, err := time.LoadLocation(r.tenant.Timezone)
if err != nil {
loc = time.UTC
}
var hour, minute int
if startsLocal != nil {
parts := strings.Split(normalizeLocalTime(*startsLocal), ":")
if len(parts) < 2 {
return nil
}
hour, _ = strconv.Atoi(parts[0])
minute, _ = strconv.Atoi(parts[1])
}
for i := range r.classSessions {
session := &r.classSessions[i]
if session.ScheduleID == nil || *session.ScheduleID != scheduleID || !session.StartsAt.After(now) {
continue
}
if startsLocal != nil {
day := session.StartsAt.In(loc)
newStart := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
var duration time.Duration
for _, tpl := range r.classTemplates {
if tpl.ID == session.TemplateID {
duration = time.Duration(tpl.DurationMinutes) * time.Minute
break
}
}
session.StartsAt = newStart
session.EndsAt = newStart.Add(duration)
}
if locationID != nil && *locationID != "" {
loc := *locationID
session.LocationID = &loc
}
}
for i := range r.bookings {
booking := &r.bookings[i]
if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") {
continue
}
for _, session := range r.classSessions {
if session.ID == *booking.ClassSessionID && session.ScheduleID != nil && *session.ScheduleID == scheduleID {
booking.StartsAt = session.StartsAt
booking.EndsAt = session.EndsAt
break
}
}
}
return nil
}
// MaterializeScheduleSessions mirrors the PG implementation: for each active
// rule, concrete sessions are added for matching weekdays within the next
// `days` days. Sessions already materialized for a rule+start are skipped.
func (r *MemoryRepository) MaterializeScheduleSessions(_ context.Context, tenantID string, now time.Time, days int) error {
if tenantID != r.tenant.ID {
return nil
}
loc, err := time.LoadLocation(r.tenant.Timezone)
if err != nil {
loc = time.UTC
}
localNow := now.In(loc)
startDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
existing := make(map[string]bool, len(r.classSessions))
for _, session := range r.classSessions {
if session.ScheduleID != nil {
existing[*session.ScheduleID+session.StartsAt.UTC().Format(time.RFC3339)] = true
}
}
for _, schedule := range r.classSchedules {
if !schedule.Active {
continue
}
var template ClassTemplateRecord
for _, tpl := range r.classTemplates {
if tpl.ID == schedule.TemplateID {
template = tpl
break
}
}
if template.ID == "" {
continue
}
for i := 0; i < days; i++ {
day := startDate.AddDate(0, 0, i)
if int(day.Weekday()) != schedule.DayOfWeek {
continue
}
parts := strings.Split(schedule.StartsLocal, ":")
if len(parts) < 2 {
continue
}
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
startsAt := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
if !startsAt.After(now) {
continue
}
key := schedule.ID + startsAt.UTC().Format(time.RFC3339)
if existing[key] {
continue
}
existing[key] = true
scheduleID := schedule.ID
r.classSessions = append(r.classSessions, ClassSessionRecord{
ID: uuid.NewString(),
TenantID: tenantID,
TemplateID: schedule.TemplateID,
LocationID: schedule.LocationID,
Title: template.Title,
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute),
Capacity: int32(template.Capacity),
ScheduleID: &scheduleID,
})
}
}
return nil
}
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,
Intake: params.Intake,
CreatedAt: time.Now().UTC(),
})
return created, nil
}
func (r *MemoryRepository) seedDemoBookings() {
now := time.Now().UTC()
serviceID := r.services[0].ID
staffID := r.staff[0].ID
locationID := r.locations[0].ID
templateID := r.classTemplates[0].ID
r.classSchedules = append(r.classSchedules,
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now},
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now},
)
r.services[0].IntakeFields = `[{"key":"health_notes","label":"Health notes or allergies","type":"text","required":false},{"key":"first_visit","label":"Is this your first visit?","type":"yesno","required":true}]`
seed := func(name, email, status string, startsAt time.Time) {
r.bookings = append(r.bookings, BookingRecord{
ID: uuid.NewString(),
TenantID: r.tenant.ID,
ServiceID: &serviceID,
StaffID: &staffID,
LocationID: &locationID,
CustomerName: name,
CustomerEmail: email,
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: status,
Reference: "BK-DEMO-" + uuid.NewString()[:8],
ManageToken: uuid.NewString(),
CreatedAt: startsAt.Add(-48 * time.Hour),
})
}
seed("Alice Johnson", "[email protected]", "confirmed", now.Add(-4*time.Hour))
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
seed("Alice Johnson", "[email protected]", "cancelled", now.Add(-74*time.Hour))
r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}`
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID
startsAt := time.Now().UTC()
customerName := "Booking Customer"
customerEmail := "[email protected]"
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) GetStaffMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) {
servicePrices := make(map[string]int, len(r.services))
for _, service := range r.services {
servicePrices[service.ID] = service.PriceCents
}
byStaff := map[string]*StaffMetricsRecord{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID || booking.StaffID == nil {
continue
}
if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) {
continue
}
rec := byStaff[*booking.StaffID]
if rec == nil {
rec = &StaffMetricsRecord{StaffID: *booking.StaffID}
byStaff[*booking.StaffID] = rec
}
rec.BookingsCount++
switch {
case booking.Status == "cancelled":
rec.CancellationsCount++
case booking.Status == "confirmed":
if booking.EndsAt.Before(time.Now().UTC()) {
rec.CompletedCount++
}
}
if booking.Status != "cancelled" {
rec.BookedMinutes += int(booking.EndsAt.Sub(booking.StartsAt).Minutes())
if booking.ServiceID != nil {
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
}
}
}
records := make([]StaffMetricsRecord, 0, len(byStaff))
for _, rec := range byStaff {
records = append(records, *rec)
}
sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount })
return records, 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
}