Files
Bookra/apps/backend/internal/db/repository.go
T
Tomas Dvorak 48c3e15a38 cleanup
2026-05-05 09:48:07 +02:00

2078 lines
65 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()
}
func (r *PGRepository) GetTenantBySlug(ctx context.Context, slug string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE slug = $1
`, slug).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetTenantByID(ctx context.Context, tenantID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE id = $1
`, tenantID).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
FROM tenants
WHERE billing_customer_id = $1
`, customerID).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), NULLIF($3, ''))
ON CONFLICT (neon_subject) DO UPDATE SET
email = COALESCE(NULLIF(EXCLUDED.email, ''), users.email),
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), users.display_name),
updated_at = now()
`, subject, email, displayName)
return err
}
func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return TenantMembershipRecord{}, err
}
defer tx.Rollback(ctx)
var userID string
if err := tx.QueryRow(ctx, `
SELECT id::text
FROM users
WHERE neon_subject = $1
`, params.Subject).Scan(&userID); err != nil {
return TenantMembershipRecord{}, err
}
record := TenantMembershipRecord{UserID: params.Subject, Role: "owner"}
if err := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, preset, locale, timezone, plan_code, subscription_status)
VALUES ($1, $2, $3, $4, $5, 'starter', 'trialing')
RETURNING id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(
&record.Tenant.ID,
&record.Tenant.Slug,
&record.Tenant.Name,
&record.Tenant.Preset,
&record.Tenant.Locale,
&record.Tenant.Timezone,
&record.Tenant.PlanCode,
&record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider,
&record.Tenant.BillingCustomerID,
&record.Tenant.BillingSubscription,
); err != nil {
return TenantMembershipRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2::uuid, 'owner')
`, record.Tenant.ID, userID); err != nil {
return TenantMembershipRecord{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, COALESCE(NULLIF($2, ''), 'Main location'), $3)
`, record.Tenant.ID, params.LocationName, params.Timezone); err != nil {
return TenantMembershipRecord{}, err
}
brandName := params.BrandName
if strings.TrimSpace(brandName) == "" {
brandName = params.Name
}
if _, err := tx.Exec(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''))
ON CONFLICT (tenant_id) DO UPDATE SET
name = EXCLUDED.name,
site_url = EXCLUDED.site_url,
logo_url = EXCLUDED.logo_url,
primary_color = EXCLUDED.primary_color,
updated_at = now()
`, record.Tenant.ID, brandName, params.SiteURL, params.LogoURL, params.PrimaryColor); err != nil {
return TenantMembershipRecord{}, err
}
serviceName := params.ServiceName
if strings.TrimSpace(serviceName) == "" {
serviceName = "First appointment"
}
duration := params.DurationMinutes
if duration <= 0 {
duration = 60
}
if _, err := tx.Exec(ctx, `
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES ($1, $2, $3, $4, $5, 0)
`, record.Tenant.ID, serviceName, duration, maxInt(params.BufferBeforeMinutes, 0), maxInt(params.BufferAfterMinutes, 0)); err != nil {
return TenantMembershipRecord{}, err
}
cancelWindowHours := params.CancelWindowHours
if cancelWindowHours <= 0 {
cancelWindowHours = 24
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings (tenant_id, cancel_window_hours, onboarding_completed)
VALUES ($1, $2, true)
ON CONFLICT (tenant_id) DO UPDATE SET
cancel_window_hours = EXCLUDED.cancel_window_hours,
onboarding_completed = true,
updated_at = now()
`, record.Tenant.ID, cancelWindowHours); err != nil {
return TenantMembershipRecord{}, err
}
blocks := params.AvailabilityBlocks
if len(blocks) == 0 {
blocks = defaultAvailabilityBlocks()
}
for _, block := range blocks {
if block.Busy {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO availability_rules (tenant_id, day_of_week, starts_local, ends_local)
VALUES ($1, $2, $3::time, $4::time)
`, record.Tenant.ID, block.DayOfWeek, block.StartsLocal, block.EndsLocal); err != nil {
return TenantMembershipRecord{}, err
}
}
for _, invite := range params.TeamInvites {
email := strings.TrimSpace(strings.ToLower(invite.Email))
if email == "" {
continue
}
role := strings.TrimSpace(invite.Role)
if role == "" {
role = "staff"
}
if _, err := tx.Exec(ctx, `
INSERT INTO team_invites (tenant_id, email, role, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (tenant_id, email) DO UPDATE SET
role = EXCLUDED.role,
status = 'pending',
updated_at = now()
`, record.Tenant.ID, email, role); err != nil {
return TenantMembershipRecord{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return TenantMembershipRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
FROM brand_profiles
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.Name,
&record.SiteURL,
&record.LogoURL,
&record.PrimaryColor,
&record.UmamiSiteID,
)
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
var record TenantMembershipRecord
err := r.pool.QueryRow(ctx, `
SELECT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id, u.neon_subject, tu.role
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
INNER JOIN tenants t ON t.id = tu.tenant_id
WHERE u.neon_subject = $1 OR tu.user_id::text = $1
ORDER BY tu.created_at ASC
LIMIT 1
`, userID).Scan(
&record.Tenant.ID,
&record.Tenant.Slug,
&record.Tenant.Name,
&record.Tenant.Preset,
&record.Tenant.Locale,
&record.Tenant.Timezone,
&record.Tenant.PlanCode,
&record.Tenant.SubscriptionStatus,
&record.Tenant.BillingProvider,
&record.Tenant.BillingCustomerID,
&record.Tenant.BillingSubscription,
&record.UserID,
&record.Role,
)
if err != nil {
return TenantMembershipRecord{}, err
}
return record, nil
}
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents
FROM services
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServiceRecord
for rows.Next() {
var record ServiceRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []AvailabilityRuleRecord
for rows.Next() {
var record AvailabilityRuleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.StaffID,
&record.DayOfWeek,
&record.StartsLocal,
&record.EndsLocal,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
ORDER BY cs.starts_at ASC
LIMIT $3
`, tenantID, from, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
rows, err := r.pool.Query(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 tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BookingRecord
for rows.Next() {
var record BookingRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.ServiceID,
&record.ClassSessionID,
&record.StaffID,
&record.LocationID,
&record.CustomerName,
&record.CustomerEmail,
&record.StartsAt,
&record.EndsAt,
&record.Status,
&record.Reference,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
var created CreatedBooking
err := r.pool.QueryRow(ctx, `
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, starts_at, ends_at,
status, reference, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING id, reference, status
`,
params.TenantID,
params.ServiceID,
params.ClassSessionID,
params.StaffID,
params.LocationID,
params.BookingMode,
params.CustomerName,
params.CustomerEmail,
params.StartsAt,
params.EndsAt,
params.Status,
params.Reference,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
return created, err
}
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
VALUES ($1,$2,$3,$4,$5)
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
VALUES ($1, $2, $3, $4)
`, params.TenantID, params.BookingID, params.Channel, params.ScheduledFor)
return err
}
func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone,
rj.booking_id, rj.channel, rj.scheduled_for,
b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status
FROM reminder_jobs rj
INNER JOIN bookings b ON b.id = rj.booking_id
INNER JOIN tenants t ON t.id = rj.tenant_id
WHERE rj.status = 'pending' AND rj.scheduled_for <= $1
ORDER BY rj.scheduled_for ASC
LIMIT $2
`, dueBefore, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ReminderJobRecord
for rows.Next() {
var record ReminderJobRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TenantName,
&record.Locale,
&record.Timezone,
&record.BookingID,
&record.Channel,
&record.ScheduledFor,
&record.CustomerName,
&record.CustomerEmail,
&record.Reference,
&record.StartsAt,
&record.Status,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
SET status = $2, dispatched_at = $3
WHERE id = $1
`, reminderJobID, status, dispatchedAt)
return err
}
func (r *PGRepository) CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO notification_delivery_logs (
tenant_id, reminder_job_id, channel, provider, recipient,
delivery_status, external_id, error_message
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''))
`, params.TenantID, params.ReminderJobID, params.Channel, params.Provider, params.Recipient,
params.Status, params.ExternalID, params.ErrorMessage)
return err
}
func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) {
var metrics DashboardMetrics
err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3) AS bookings_count,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'cancelled') AS cancellations_count,
COALESCE(
ROUND(
100.0 * COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed')
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
)
return metrics, err
}
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
var record BillingSnapshotRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, COALESCE(currency, 'czk'), price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
FROM billing_snapshots
WHERE tenant_id = $1
`, tenantID).Scan(
&record.TenantID,
&record.BillingProvider,
&record.BillingCustomerID,
&record.BillingSubscriptionID,
&record.Status,
&record.PlanCode,
&record.Currency,
&record.PriceID,
&record.CancelAtPeriodEnd,
&record.CurrentPeriodStart,
&record.CurrentPeriodEnd,
&record.PaymentMethodBrand,
&record.PaymentMethodLast4,
&record.LastSyncedAt,
)
return record, err
}
func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO billing_snapshots (
tenant_id, billing_provider, billing_customer_id, billing_subscription_id, status, plan_code, currency, price_id,
cancel_at_period_end, current_period_start, current_period_end,
payment_method_brand, payment_method_last4, last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (tenant_id) DO UPDATE SET
billing_provider = EXCLUDED.billing_provider,
billing_customer_id = EXCLUDED.billing_customer_id,
billing_subscription_id = EXCLUDED.billing_subscription_id,
status = EXCLUDED.status,
plan_code = EXCLUDED.plan_code,
currency = EXCLUDED.currency,
price_id = EXCLUDED.price_id,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
payment_method_brand = EXCLUDED.payment_method_brand,
payment_method_last4 = EXCLUDED.payment_method_last4,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = now()
`, params.TenantID, firstNonEmpty(params.BillingProvider, "paddle"), params.BillingCustomerID, params.BillingSubscriptionID, params.Status, params.PlanCode,
firstNonEmpty(params.Currency, "czk"), params.PriceID, params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', plan_code = $2, subscription_status = $3, billing_subscription_id = $4, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
return err
}
func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error) {
result, err := r.pool.Exec(ctx, `
INSERT INTO subscription_events (tenant_id, billing_provider, billing_provider_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (billing_provider, billing_provider_event_id) DO NOTHING
`, tenantID, firstNonEmpty(provider, "paddle"), eventID, eventType, payload)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
// ============================================
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE tenant_id = $1
ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []LocationRecord
for rows.Next() {
var rec LocationRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, timezone, created_at
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO locations (tenant_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, timezone, created_at
`, params.TenantID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
var rec LocationRecord
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
updated_at = now()
WHERE id = $1
RETURNING id, tenant_id, name, timezone, created_at
`, locationID, params.Name, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteLocation(ctx context.Context, locationID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM locations WHERE id = $1`, locationID)
return err
}
// ============================================
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
// ============================================
func (r *PGRepository) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
FROM availability_exceptions
WHERE tenant_id = $1 AND starts_at <= $3 AND ends_at >= $2
ORDER BY starts_at
`, tenantID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var records []BlockedDayRecord
for rows.Next() {
var rec BlockedDayRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO availability_exceptions (tenant_id, staff_id, starts_at, ends_at, kind, reason)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, params.TenantID, params.StaffID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
var rec BlockedDayRecord
err := r.pool.QueryRow(ctx, `
UPDATE availability_exceptions
SET starts_at = COALESCE($2, starts_at),
ends_at = COALESCE($3, ends_at),
kind = COALESCE($4, kind),
reason = COALESCE($5, reason)
WHERE id = $1
RETURNING id, tenant_id, staff_id, starts_at, ends_at, kind, reason, created_at
`, blockedDayID, params.StartsAt, params.EndsAt, params.Kind, params.Reason).Scan(&rec.ID, &rec.TenantID, &rec.StaffID, &rec.StartsAt, &rec.EndsAt, &rec.Kind, &rec.Reason, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM availability_exceptions WHERE id = $1`, blockedDayID)
return err
}
// ============================================
// 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)
// ============================================
func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT tenant_id, staff_id, day_of_week, starts_local, ends_local
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkingHoursRecord
for rows.Next() {
var rec WorkingHoursRecord
if err := rows.Scan(&rec.TenantID, &rec.StaffID, &rec.DayOfWeek, &rec.StartsLocal, &rec.EndsLocal); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error {
_, err := r.pool.Exec(ctx, `
UPDATE availability_rules
SET starts_local = COALESCE($3, starts_local),
ends_local = COALESCE($4, ends_local)
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
return err
}
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
}