Files
Bookra/apps/backend/internal/db/repository.go
T
Tomas Dvorak cf3315e8fc
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
cleanup
2026-05-05 09:48:15 +02:00

1341 lines
40 KiB
Go

package db
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error)
GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error)
GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error)
EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error
CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error)
GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error)
GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error)
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error)
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error
GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error)
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
DeleteLocation(ctx context.Context, locationID string) error
// Blocked Days / Availability Exceptions
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error)
DeleteBlockedDay(ctx context.Context, blockedDayID string) error
// Customer Management
ListCustomersByTenant(ctx context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error)
GetCustomerByID(ctx context.Context, customerID string) (CustomerRecord, error)
GetCustomerByEmail(ctx context.Context, tenantID string, email string) (CustomerRecord, error)
CreateCustomer(ctx context.Context, params CreateCustomerParams) (CustomerRecord, error)
UpdateCustomer(ctx context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error)
DeleteCustomer(ctx context.Context, customerID string) error
GetCustomerBookingsCount(ctx context.Context, customerID string) (int, error)
GetCustomerLastBooking(ctx context.Context, customerID string) (*time.Time, error)
// Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
// Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error
}
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 TenantMembershipRecord struct {
Tenant TenantRecord
UserID string
Role string
}
type CreateTenantForUserParams struct {
Subject string
Name string
Slug string
Preset string
Locale string
Timezone string
BrandName string
SiteURL string
LogoURL string
PrimaryColor string
LocationName string
ServiceName string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
CancelWindowHours int
AvailabilityBlocks []AvailabilityBlockRecord
TeamInvites []TeamInviteRecord
}
type BrandProfileRecord struct {
TenantID string
Name string
SiteURL string
LogoURL string
PrimaryColor string
UmamiSiteID string
}
type AvailabilityBlockRecord struct {
DayOfWeek int
StartsLocal string
EndsLocal string
Busy bool
}
type TeamInviteRecord struct {
Email string
Role string
}
type ServiceRecord struct {
ID string
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
}
type AvailabilityRuleRecord struct {
ID string
TenantID string
StaffID *string
DayOfWeek int
StartsLocal string
EndsLocal string
}
type ClassSessionRecord struct {
ID string
TenantID string
TemplateID string
LocationID *string
Title string
StartsAt time.Time
EndsAt time.Time
Capacity int32
}
type BookingRecord struct {
ID string
TenantID string
ServiceID *string
ClassSessionID *string
StaffID *string
LocationID *string
CustomerName string
CustomerEmail string
StartsAt time.Time
EndsAt time.Time
Status string
Reference string
}
type CreateBookingParams struct {
TenantID string
ServiceID *string
ClassSessionID *string
StaffID *string
LocationID *string
BookingMode string
CustomerName string
CustomerEmail string
StartsAt time.Time
EndsAt time.Time
Status string
Reference string
Notes string
}
type CreatedBooking struct {
ID string
Reference string
Status string
}
type WaitlistEntryParams struct {
TenantID string
ClassSessionID string
CustomerName string
CustomerEmail string
Position int
}
type DashboardMetrics struct {
BookingsCount int
CancellationsCount int
UtilizationPercent int
}
type ReminderJobParams struct {
TenantID string
BookingID string
Channel string
ScheduledFor time.Time
}
type ReminderJobRecord struct {
ID string
TenantID string
TenantName string
Locale string
Timezone string
BookingID string
Channel string
ScheduledFor time.Time
CustomerName string
CustomerEmail string
Reference string
StartsAt time.Time
Status string
}
type NotificationDeliveryLogParams struct {
TenantID string
ReminderJobID string
Channel string
Provider string
Recipient string
Status string
ExternalID string
ErrorMessage string
}
type BillingSnapshotRecord struct {
TenantID string
BillingProvider string
BillingCustomerID string
BillingSubscriptionID string
Status string
PlanCode string
Currency string
PriceID string
CancelAtPeriodEnd bool
CurrentPeriodStart *time.Time
CurrentPeriodEnd *time.Time
PaymentMethodBrand string
PaymentMethodLast4 string
LastSyncedAt *time.Time
}
// Location / Zone Records
type LocationRecord struct {
ID string
TenantID string
Name string
Timezone string
CreatedAt time.Time
}
type CreateLocationParams struct {
TenantID string
Name string
Timezone string
}
type UpdateLocationParams struct {
Name *string
Timezone *string
}
// Blocked Day Records
type BlockedDayRecord struct {
ID string
TenantID string
StaffID *string
StartsAt time.Time
EndsAt time.Time
Kind string
Reason string
CreatedAt time.Time
}
type CreateBlockedDayParams struct {
TenantID string
StaffID *string
StartsAt time.Time
EndsAt time.Time
Kind string
Reason string
}
type UpdateBlockedDayParams struct {
StartsAt *time.Time
EndsAt *time.Time
Kind *string
Reason *string
}
// Customer Records
type CustomerRecord struct {
ID string
TenantID string
Name string
Email string
Phone *string
Status string
CreatedAt time.Time
Notes *string
}
type CreateCustomerParams struct {
TenantID string
Name string
Email string
Phone *string
Status string
Notes *string
}
type UpdateCustomerParams struct {
Name *string
Email *string
Phone *string
Status *string
Notes *string
}
// Working Hours Records
type WorkingHoursRecord struct {
TenantID string
StaffID *string
DayOfWeek int
StartsLocal string
EndsLocal string
}
type UpdateWorkingHoursParams struct {
StartsLocal *string
EndsLocal *string
IsOpen *bool
}
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, starts_at, ends_at, status, reference
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference)
return rec, err
}
func (r *PGRepository) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error {
_, err := r.pool.Exec(ctx, `
UPDATE bookings SET status = $2, updated_at = now() WHERE id = $1
`, bookingID, status)
return err
}
func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE bookings SET starts_at = $2, ends_at = $3, updated_at = now() WHERE id = $1
`, bookingID, startsAt, endsAt)
return err
}
// ============================================
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classSessions []ClassSessionRecord
bookings []BookingRecord
waitlistSize int
reminderJobs []ReminderJobRecord
deliveryLogs []NotificationDeliveryLogParams
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
}
func NewMemoryRepository() *MemoryRepository {
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
now := time.Now().UTC()
return &MemoryRepository{
tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
membership: TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
UserID: "demo-owner",
Role: "owner",
},
brand: BrandProfileRecord{
TenantID: tenantID,
Name: "Studio Atelier",
SiteURL: "https://studio-atelier.example",
PrimaryColor: "#a65c3e",
},
services: []ServiceRecord{
{
ID: serviceID,
TenantID: tenantID,
Name: "Signature treatment",
DurationMinutes: 60,
BufferBeforeMinutes: 0,
BufferAfterMinutes: 15,
PriceCents: 120000,
},
},
rules: []AvailabilityRuleRecord{
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 1, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 2, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 3, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
},
classSessions: []ClassSessionRecord{
{
ID: sessionID,
TenantID: tenantID,
TemplateID: templateID,
LocationID: &locationID,
Title: "Small group mobility class",
StartsAt: now.Add(48 * time.Hour),
EndsAt: now.Add(49 * time.Hour),
Capacity: 4,
},
},
bookings: []BookingRecord{},
reminderJobs: []ReminderJobRecord{},
deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID,
BillingProvider: "paddle",
BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "",
Status: "active",
PlanCode: "pro",
Currency: "czk",
PriceID: "",
},
recordedEvents: map[string]struct{}{},
locations: []LocationRecord{
{ID: locationID, TenantID: tenantID, Name: "Main Studio", Timezone: "Europe/Prague", CreatedAt: now},
},
blockedDays: []BlockedDayRecord{},
customers: []CustomerRecord{
{ID: uuid.NewString(), TenantID: tenantID, Name: "Alice Johnson", Email: "alice@example.com", Phone: stringPtr("+420123456789"), Status: "vip", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Bob Smith", Email: "bob@example.com", Status: "active", CreatedAt: now},
{ID: uuid.NewString(), TenantID: tenantID, Name: "Carol White", Email: "carol@example.com", Status: "active", CreatedAt: now},
},
workingHours: []WorkingHoursRecord{
{TenantID: tenantID, DayOfWeek: 1, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 2, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 3, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 4, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 5, StartsLocal: "09:00", EndsLocal: "17:00"},
{TenantID: tenantID, DayOfWeek: 6, StartsLocal: "10:00", EndsLocal: "14:00"},
{TenantID: tenantID, DayOfWeek: 0, StartsLocal: "", EndsLocal: ""},
},
}
}
func (r *MemoryRepository) GetTenantBySlug(_ context.Context, slug string) (TenantRecord, error) {
if slug != r.tenant.Slug {
return TenantRecord{}, pgx.ErrNoRows
}
return r.tenant, nil
}
func (r *MemoryRepository) GetTenantByID(_ context.Context, tenantID string) (TenantRecord, error) {
if tenantID == r.tenant.ID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetTenantByBillingCustomerID(_ context.Context, customerID string) (TenantRecord, error) {
if r.billingSnapshot.BillingCustomerID == customerID {
return r.tenant, nil
}
return TenantRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) EnsureUserIdentity(_ context.Context, subject string, email string, displayName string) error {
if strings.TrimSpace(subject) == "" {
return nil
}
r.membership.UserID = subject
_ = email
_ = displayName
return nil
}
func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tenantID := uuid.NewString()
r.tenant = TenantRecord{
ID: tenantID,
Slug: params.Slug,
Name: params.Name,
Preset: params.Preset,
Locale: params.Locale,
Timezone: params.Timezone,
PlanCode: "starter",
SubscriptionStatus: "trialing",
}
r.membership = TenantMembershipRecord{
Tenant: r.tenant,
UserID: params.Subject,
Role: "owner",
}
r.brand = BrandProfileRecord{
TenantID: tenantID,
Name: firstNonEmpty(params.BrandName, params.Name),
SiteURL: params.SiteURL,
LogoURL: params.LogoURL,
PrimaryColor: params.PrimaryColor,
}
serviceName := firstNonEmpty(params.ServiceName, "First appointment")
duration := params.DurationMinutes
if duration <= 0 {
duration = 60
}
r.services = []ServiceRecord{{
ID: uuid.NewString(),
TenantID: tenantID,
Name: serviceName,
DurationMinutes: duration,
BufferBeforeMinutes: maxInt(params.BufferBeforeMinutes, 0),
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0,
}}
r.rules = nil
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
blocks = defaultAvailabilityBlocks()
}
for _, block := range blocks {
if block.Busy {
continue
}
r.rules = append(r.rules, AvailabilityRuleRecord{
ID: uuid.NewString(),
TenantID: tenantID,
DayOfWeek: block.DayOfWeek,
StartsLocal: block.StartsLocal,
EndsLocal: block.EndsLocal,
})
}
r.classSessions = nil
r.bookings = nil
r.reminderJobs = nil
return r.membership, nil
}
func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) {
if userID == "" || userID == "demo-owner" {
return r.membership, nil
}
if userID == r.membership.UserID {
return r.membership, nil
}
return TenantMembershipRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
if tenantID != r.tenant.ID {
return BrandProfileRecord{}, pgx.ErrNoRows
}
if r.brand.Name == "" {
r.brand = BrandProfileRecord{TenantID: tenantID, Name: r.tenant.Name}
}
return r.brand, nil
}
func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID string) ([]ServiceRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]ServiceRecord(nil), r.services...), nil
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]AvailabilityRuleRecord(nil), r.rules...), nil
}
func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassSessionRecord
for _, session := range r.classSessions {
if session.StartsAt.Before(from) {
continue
}
out = append(out, session)
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
var out []BookingRecord
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
if booking.StartsAt.Before(to) && booking.EndsAt.After(from) {
out = append(out, booking)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
return out, nil
}
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
}
r.bookings = append(r.bookings, BookingRecord{
ID: created.ID,
TenantID: params.TenantID,
ServiceID: params.ServiceID,
ClassSessionID: params.ClassSessionID,
StaffID: params.StaffID,
LocationID: params.LocationID,
CustomerName: params.CustomerName,
CustomerEmail: params.CustomerEmail,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Status: params.Status,
Reference: params.Reference,
})
return created, nil
}
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
r.waitlistSize++
return nil
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID
startsAt := time.Now().UTC()
customerName := "Booking Customer"
customerEmail := "customer@example.com"
for _, booking := range r.bookings {
if booking.ID == params.BookingID {
reference = booking.Reference
startsAt = booking.StartsAt
customerName = booking.CustomerName
customerEmail = booking.CustomerEmail
break
}
}
r.reminderJobs = append(r.reminderJobs, ReminderJobRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TenantName: r.tenant.Name,
Locale: r.tenant.Locale,
Timezone: r.tenant.Timezone,
BookingID: params.BookingID,
Channel: params.Channel,
ScheduledFor: params.ScheduledFor,
CustomerName: customerName,
CustomerEmail: customerEmail,
Reference: reference,
StartsAt: startsAt,
Status: "pending",
})
return nil
}
func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
var out []ReminderJobRecord
for _, job := range r.reminderJobs {
if job.Status == "pending" && !job.ScheduledFor.After(dueBefore) {
out = append(out, job)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ScheduledFor.Before(out[j].ScheduledFor) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *MemoryRepository) MarkReminderJobDispatched(_ context.Context, reminderJobID string, status string, _ time.Time) error {
for index := range r.reminderJobs {
if r.reminderJobs[index].ID == reminderJobID {
r.reminderJobs[index].Status = status
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) CreateNotificationDeliveryLog(_ context.Context, params NotificationDeliveryLogParams) error {
r.deliveryLogs = append(r.deliveryLogs, params)
return nil
}
func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
metrics := DashboardMetrics{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) {
continue
}
metrics.BookingsCount++
if booking.Status == "cancelled" {
metrics.CancellationsCount++
}
if booking.Status == "confirmed" {
metrics.UtilizationPercent++
}
}
if metrics.BookingsCount == 0 {
metrics.UtilizationPercent = 0
} else {
metrics.UtilizationPercent = int(float64(metrics.UtilizationPercent) / float64(metrics.BookingsCount) * 100)
}
return metrics, nil
}
func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) {
if tenantID != r.tenant.ID {
return BillingSnapshotRecord{}, pgx.ErrNoRows
}
return r.billingSnapshot, nil
}
func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params BillingSnapshotRecord) error {
r.billingSnapshot = params
return nil
}
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, customerID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.BillingProvider = "paddle"
r.tenant.BillingCustomerID = &customerID
r.membership.Tenant.BillingProvider = "paddle"
r.membership.Tenant.BillingCustomerID = &customerID
r.billingSnapshot.BillingProvider = "paddle"
r.billingSnapshot.BillingCustomerID = customerID
return nil
}
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.PlanCode = planCode
r.tenant.SubscriptionStatus = subscriptionStatus
r.tenant.BillingProvider = "paddle"
r.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.membership.Tenant.BillingProvider = "paddle"
r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.BillingProvider = "paddle"
r.billingSnapshot.BillingSubscriptionID = subscriptionID
return nil
}
func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) {
key := firstNonEmpty(provider, "paddle") + ":" + eventID
if _, exists := r.recordedEvents[key]; exists {
return false, nil
}
if len(payload) > 0 {
var raw json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
return false, err
}
}
r.recordedEvents[key] = struct{}{}
return true, nil
}
// ============================================
// LOCATION / ZONE METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListLocationsByTenant(_ context.Context, tenantID string) ([]LocationRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.locations, nil
}
func (r *MemoryRepository) GetLocationByID(_ context.Context, locationID string) (LocationRecord, error) {
for _, loc := range r.locations {
if loc.ID == locationID {
return loc, nil
}
}
return LocationRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocationParams) (LocationRecord, error) {
loc := LocationRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Timezone: params.Timezone,
CreatedAt: time.Now(),
}
r.locations = append(r.locations, loc)
return loc, nil
}
func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
for i, loc := range r.locations {
if loc.ID == locationID {
if params.Name != nil {
r.locations[i].Name = *params.Name
}
if params.Timezone != nil {
r.locations[i].Timezone = *params.Timezone
}
return r.locations[i], nil
}
}
return LocationRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteLocation(_ context.Context, locationID string) error {
for i, loc := range r.locations {
if loc.ID == locationID {
r.locations = append(r.locations[:i], r.locations[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// BLOCKED DAYS METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListBlockedDaysByTenant(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
var out []BlockedDayRecord
for _, bd := range r.blockedDays {
if bd.TenantID == tenantID && !bd.StartsAt.After(to) && !bd.EndsAt.Before(from) {
out = append(out, bd)
}
}
return out, nil
}
func (r *MemoryRepository) CreateBlockedDay(_ context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
bd := BlockedDayRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
StaffID: params.StaffID,
StartsAt: params.StartsAt,
EndsAt: params.EndsAt,
Kind: params.Kind,
Reason: params.Reason,
CreatedAt: time.Now(),
}
r.blockedDays = append(r.blockedDays, bd)
return bd, nil
}
func (r *MemoryRepository) UpdateBlockedDay(_ context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
for i, bd := range r.blockedDays {
if bd.ID == blockedDayID {
if params.StartsAt != nil {
r.blockedDays[i].StartsAt = *params.StartsAt
}
if params.EndsAt != nil {
r.blockedDays[i].EndsAt = *params.EndsAt
}
if params.Kind != nil {
r.blockedDays[i].Kind = *params.Kind
}
if params.Reason != nil {
r.blockedDays[i].Reason = *params.Reason
}
return r.blockedDays[i], nil
}
}
return BlockedDayRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteBlockedDay(_ context.Context, blockedDayID string) error {
for i, bd := range r.blockedDays {
if bd.ID == blockedDayID {
r.blockedDays = append(r.blockedDays[:i], r.blockedDays[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// CUSTOMER METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListCustomersByTenant(_ context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
start := offset
if start > len(r.customers) {
return []CustomerRecord{}, nil
}
end := start + limit
if end > len(r.customers) {
end = len(r.customers)
}
return r.customers[start:end], nil
}
func (r *MemoryRepository) GetCustomerByID(_ context.Context, customerID string) (CustomerRecord, error) {
for _, c := range r.customers {
if c.ID == customerID {
return c, nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetCustomerByEmail(_ context.Context, tenantID string, email string) (CustomerRecord, error) {
for _, c := range r.customers {
if c.TenantID == tenantID && c.Email == email {
return c, nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateCustomer(_ context.Context, params CreateCustomerParams) (CustomerRecord, error) {
c := CustomerRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Email: params.Email,
Phone: params.Phone,
Status: params.Status,
CreatedAt: time.Now(),
Notes: params.Notes,
}
r.customers = append(r.customers, c)
return c, nil
}
func (r *MemoryRepository) UpdateCustomer(_ context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error) {
for i, c := range r.customers {
if c.ID == customerID {
if params.Name != nil {
r.customers[i].Name = *params.Name
}
if params.Email != nil {
r.customers[i].Email = *params.Email
}
if params.Phone != nil {
r.customers[i].Phone = params.Phone
}
if params.Status != nil {
r.customers[i].Status = *params.Status
}
if params.Notes != nil {
r.customers[i].Notes = params.Notes
}
return r.customers[i], nil
}
}
return CustomerRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteCustomer(_ context.Context, customerID string) error {
for i, c := range r.customers {
if c.ID == customerID {
r.customers = append(r.customers[:i], r.customers[i+1:]...)
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) GetCustomerBookingsCount(_ context.Context, customerID string) (int, error) {
count := 0
for _, b := range r.bookings {
for _, c := range r.customers {
if c.ID == customerID && b.CustomerEmail == c.Email {
count++
}
}
}
return count, nil
}
func (r *MemoryRepository) GetCustomerLastBooking(_ context.Context, customerID string) (*time.Time, error) {
var lastTime *time.Time
var customerEmail string
for _, c := range r.customers {
if c.ID == customerID {
customerEmail = c.Email
break
}
}
if customerEmail == "" {
return nil, nil
}
for _, b := range r.bookings {
if b.CustomerEmail == customerEmail {
if lastTime == nil || b.StartsAt.After(*lastTime) {
lastTime = &b.StartsAt
}
}
}
return lastTime, nil
}
// ============================================
// BOOKING MANAGEMENT METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.Reference == reference {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings {
if b.ID == bookingID {
r.bookings[i].Status = status
return nil
}
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error {
for i, b := range r.bookings {
if b.ID == bookingID {
r.bookings[i].StartsAt = startsAt
r.bookings[i].EndsAt = endsAt
return nil
}
}
return pgx.ErrNoRows
}
// ============================================
// WORKING HOURS METHODS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) {
if tenantID != r.tenant.ID {
return nil, pgx.ErrNoRows
}
return r.workingHours, nil
}
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
for i, wh := range r.workingHours {
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek {
if params.StartsLocal != nil {
r.workingHours[i].StartsLocal = *params.StartsLocal
}
if params.EndsLocal != nil {
r.workingHours[i].EndsLocal = *params.EndsLocal
}
return nil
}
}
return pgx.ErrNoRows
}
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
}