mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 12:33:00 +00:00
2078 lines
65 KiB
Go
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
|
|
}
|