Files
Bookra/apps/backend/internal/db/repository.go
T
Tomas Dvorak 035ac8ddb5 first commit
2026-04-10 12:01:36 +02:00

1071 lines
32 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)
GetTenantByStripeCustomerID(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)
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
UpdateTenantStripeCustomerID(ctx context.Context, tenantID string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
RecordStripeEvent(ctx context.Context, tenantID string, eventID string, eventType string, payload []byte) (bool, error)
}
type TenantRecord struct {
ID string
Slug string
Name string
Preset string
Locale string
Timezone string
PlanCode string
SubscriptionStatus string
StripeCustomerID *string
StripeSubscription *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
}
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
StripeCustomerID string
StripeSubscriptionID string
Status string
PlanCode string
PriceID string
CancelAtPeriodEnd bool
CurrentPeriodStart *time.Time
CurrentPeriodEnd *time.Time
PaymentMethodBrand string
PaymentMethodLast4 string
LastSyncedAt *time.Time
}
type PGRepository struct {
pool *pgxpool.Pool
}
func NewRepository(pools *Pools) Repository {
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, stripe_customer_id, stripe_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.StripeCustomerID,
&record.StripeSubscription,
)
if err != nil {
return TenantRecord{}, err
}
return record, nil
}
func (r *PGRepository) GetTenantByStripeCustomerID(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, stripe_customer_id, stripe_subscription_id
FROM tenants
WHERE stripe_customer_id = $1
`, customerID).Scan(
&record.ID,
&record.Slug,
&record.Name,
&record.Preset,
&record.Locale,
&record.Timezone,
&record.PlanCode,
&record.SubscriptionStatus,
&record.StripeCustomerID,
&record.StripeSubscription,
)
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, stripe_customer_id, stripe_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.StripeCustomerID,
&record.Tenant.StripeSubscription,
); 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, 'Main location', $2)
`, record.Tenant.ID, params.Timezone); err != nil {
return TenantMembershipRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return TenantMembershipRecord{}, err
}
return record, nil
}
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.stripe_customer_id, t.stripe_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.StripeCustomerID,
&record.Tenant.StripeSubscription,
&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, stripe_customer_id, stripe_subscription_id, status, plan_code, 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.StripeCustomerID,
&record.StripeSubscriptionID,
&record.Status,
&record.PlanCode,
&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, stripe_customer_id, stripe_subscription_id, status, plan_code, 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)
ON CONFLICT (tenant_id) DO UPDATE SET
stripe_customer_id = EXCLUDED.stripe_customer_id,
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
status = EXCLUDED.status,
plan_code = EXCLUDED.plan_code,
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, params.StripeCustomerID, params.StripeSubscriptionID, params.Status, params.PlanCode, params.PriceID,
params.CancelAtPeriodEnd, params.CurrentPeriodStart, params.CurrentPeriodEnd,
params.PaymentMethodBrand, params.PaymentMethodLast4, params.LastSyncedAt)
return err
}
func (r *PGRepository) UpdateTenantStripeCustomerID(ctx context.Context, tenantID string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET stripe_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 plan_code = $2, subscription_status = $3, stripe_subscription_id = $4, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
return err
}
func (r *PGRepository) RecordStripeEvent(ctx context.Context, tenantID string, eventID string, eventType string, payload []byte) (bool, error) {
result, err := r.pool.Exec(ctx, `
INSERT INTO subscription_events (tenant_id, stripe_event_id, event_type, payload, processed_at)
VALUES ($1, $2, $3, $4::jsonb, now())
ON CONFLICT (stripe_event_id) DO NOTHING
`, tenantID, eventID, eventType, payload)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classSessions []ClassSessionRecord
bookings []BookingRecord
waitlistSize int
reminderJobs []ReminderJobRecord
deliveryLogs []NotificationDeliveryLogParams
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
}
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: "growth",
SubscriptionStatus: "active",
StripeCustomerID: stringPtr("cus_demo_bookra"),
},
membership: TenantMembershipRecord{
Tenant: TenantRecord{
ID: tenantID,
Slug: "studio-atelier",
Name: "Studio Atelier",
Preset: "studio",
Locale: "cs",
Timezone: "Europe/Prague",
PlanCode: "growth",
SubscriptionStatus: "active",
StripeCustomerID: stringPtr("cus_demo_bookra"),
},
UserID: "demo-owner",
Role: "owner",
},
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,
StripeCustomerID: "cus_demo_bookra",
StripeSubscriptionID: "",
Status: "none",
PlanCode: "growth",
PriceID: "",
},
recordedEvents: map[string]struct{}{},
}
}
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) GetTenantByStripeCustomerID(_ context.Context, customerID string) (TenantRecord, error) {
if r.billingSnapshot.StripeCustomerID == 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.services = nil
r.rules = nil
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) 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 := "Demo Customer"
customerEmail := "customer@bookra.dev"
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) UpdateTenantStripeCustomerID(_ context.Context, tenantID string, customerID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.StripeCustomerID = &customerID
r.membership.Tenant.StripeCustomerID = &customerID
r.billingSnapshot.StripeCustomerID = 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.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.StripeSubscriptionID = subscriptionID
return nil
}
func (r *MemoryRepository) RecordStripeEvent(_ context.Context, _ string, eventID string, _ string, payload []byte) (bool, error) {
if _, exists := r.recordedEvents[eventID]; 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[eventID] = struct{}{}
return true, nil
}
func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s", prefix, at.UTC().Format("20060102150405"))
}
func stringPtr(value string) *string {
return &value
}