mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
when a class seat frees, on both dashboard and customer cancel paths;
promoted customers get a confirmation email, waitlist ledger marked
resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
ResolveWaitlistEntry added to the repository surface
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2061 lines
63 KiB
Go
2061 lines
63 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)
|
|
GetTenantMembershipBySubject(ctx context.Context, subject string) (TenantMembershipRecord, error)
|
|
IsAdminSubject(ctx context.Context, subject string) (bool, error)
|
|
GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error)
|
|
GetBrandProfile(ctx context.Context, tenantID string) (BrandProfileRecord, error)
|
|
ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error)
|
|
ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error)
|
|
ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error)
|
|
GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error)
|
|
CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error)
|
|
DeleteClassSession(ctx context.Context, sessionID string) error
|
|
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
|
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
|
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
|
DeleteClassTemplate(ctx context.Context, templateID string) 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
|
|
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) 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, provider string, customerID string) error
|
|
UpdateTenantBillingState(ctx context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error
|
|
UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error)
|
|
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
|
|
|
|
// Auth methods
|
|
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
|
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
|
|
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
|
|
UpdateLastLogin(ctx context.Context, userID string) error
|
|
MarkEmailVerified(ctx context.Context, userID string) error
|
|
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
|
|
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
|
|
MarkMagicLinkUsed(ctx context.Context, token string) error
|
|
|
|
// Admin methods
|
|
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
|
|
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
|
|
GetPlatformStats(ctx context.Context) (PlatformStats, error)
|
|
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
|
|
UpdateUserRole(ctx context.Context, userID, role string) error
|
|
|
|
// Location / Zone Management
|
|
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
|
|
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
|
|
CreateLocation(ctx context.Context, params CreateLocationParams) (LocationRecord, error)
|
|
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
|
|
DeleteLocation(ctx context.Context, locationID string) error
|
|
|
|
// Service Management
|
|
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
|
|
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
|
|
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
|
|
DeleteService(ctx context.Context, serviceID string) error
|
|
|
|
// Staff Management
|
|
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
|
|
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
|
|
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
|
|
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
|
|
DeleteStaff(ctx context.Context, staffID string) error
|
|
|
|
// Blocked Days / Availability Exceptions
|
|
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
|
|
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
|
|
UpdateBlockedDay(ctx context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error)
|
|
DeleteBlockedDay(ctx context.Context, blockedDayID string) error
|
|
|
|
// Customer Management
|
|
ListCustomersByTenant(ctx context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error)
|
|
GetCustomerByID(ctx context.Context, customerID string) (CustomerRecord, error)
|
|
GetCustomerByEmail(ctx context.Context, tenantID string, email string) (CustomerRecord, error)
|
|
CreateCustomer(ctx context.Context, params CreateCustomerParams) (CustomerRecord, error)
|
|
UpdateCustomer(ctx context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error)
|
|
DeleteCustomer(ctx context.Context, customerID string) error
|
|
GetCustomerBookingsCount(ctx context.Context, customerID string) (int, error)
|
|
GetCustomerLastBooking(ctx context.Context, customerID string) (*time.Time, error)
|
|
|
|
// Customer Booking Management
|
|
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
|
|
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
|
|
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, staffID *string) ([]WorkingHoursRecord, error)
|
|
UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error
|
|
|
|
// Email templates
|
|
ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error)
|
|
GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error)
|
|
UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error)
|
|
|
|
// SMS
|
|
GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error)
|
|
UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error
|
|
CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error)
|
|
GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error)
|
|
GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error)
|
|
ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error)
|
|
ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error)
|
|
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
|
|
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
|
|
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
|
|
}
|
|
|
|
type TenantRecord struct {
|
|
ID string
|
|
Slug string
|
|
Name string
|
|
Preset string
|
|
Locale string
|
|
Timezone string
|
|
PlanCode string
|
|
SubscriptionStatus string
|
|
BillingProvider string
|
|
BillingCustomerID *string
|
|
BillingSubscription *string
|
|
}
|
|
|
|
type UserRecord struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
Name *string
|
|
PasswordHash *string
|
|
EmailVerified bool
|
|
Provider string
|
|
Role string
|
|
CreatedAt time.Time
|
|
LastLoginAt *time.Time
|
|
}
|
|
|
|
type MagicLinkRecord struct {
|
|
Token string
|
|
UserID uuid.UUID
|
|
Email string
|
|
Used bool
|
|
ExpiresAt time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type PlatformStats struct {
|
|
TotalTenants int64 `json:"totalTenants"`
|
|
TotalUsers int64 `json:"totalUsers"`
|
|
ActiveSubscriptions int64 `json:"activeSubscriptions"`
|
|
TrialSubscriptions int64 `json:"trialSubscriptions"`
|
|
BookingsThisMonth int64 `json:"bookingsThisMonth"`
|
|
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
|
|
}
|
|
|
|
type AdminAuditLogParams struct {
|
|
AdminUserID string
|
|
Action string
|
|
ResourceType string
|
|
ResourceID string
|
|
Details map[string]any
|
|
IPAddress string
|
|
UserAgent string
|
|
}
|
|
|
|
type TenantMembershipRecord struct {
|
|
Tenant TenantRecord
|
|
UserID string
|
|
Role string
|
|
}
|
|
|
|
type CreateTenantForUserParams struct {
|
|
Subject string
|
|
Name string
|
|
Slug string
|
|
Preset string
|
|
Locale string
|
|
Timezone string
|
|
BrandName string
|
|
SiteURL string
|
|
LogoURL string
|
|
PrimaryColor string
|
|
LocationName string
|
|
ServiceName string
|
|
DurationMinutes int
|
|
BufferBeforeMinutes int
|
|
BufferAfterMinutes int
|
|
CancelWindowHours int
|
|
AvailabilityBlocks []AvailabilityBlockRecord
|
|
TeamInvites []TeamInviteRecord
|
|
}
|
|
|
|
type BrandProfileRecord struct {
|
|
TenantID string
|
|
Name string
|
|
SiteURL string
|
|
LogoURL string
|
|
PrimaryColor string
|
|
UmamiSiteID string
|
|
}
|
|
|
|
type UpdateBrandProfileParams struct {
|
|
Name *string
|
|
SiteURL *string
|
|
LogoURL *string
|
|
PrimaryColor *string
|
|
}
|
|
|
|
type AvailabilityBlockRecord struct {
|
|
DayOfWeek int
|
|
StartsLocal string
|
|
EndsLocal string
|
|
Busy bool
|
|
}
|
|
|
|
type TeamInviteRecord struct {
|
|
Email string
|
|
Role string
|
|
}
|
|
|
|
type ServiceRecord struct {
|
|
ID string
|
|
TenantID string
|
|
Name string
|
|
DurationMinutes int
|
|
BufferBeforeMinutes int
|
|
BufferAfterMinutes int
|
|
PriceCents int
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateServiceParams struct {
|
|
TenantID string
|
|
Name string
|
|
DurationMinutes int
|
|
BufferBeforeMinutes int
|
|
BufferAfterMinutes int
|
|
PriceCents int
|
|
}
|
|
|
|
// Pointer fields distinguish "not sent" (keep current value) from an explicit
|
|
// zero value, which is meaningful for buffers and price.
|
|
type UpdateServiceParams struct {
|
|
Name *string
|
|
DurationMinutes *int
|
|
BufferBeforeMinutes *int
|
|
BufferAfterMinutes *int
|
|
PriceCents *int
|
|
}
|
|
|
|
type StaffRecord struct {
|
|
ID string
|
|
TenantID string
|
|
LocationID *string
|
|
DisplayName string
|
|
Active bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateStaffParams struct {
|
|
TenantID string
|
|
LocationID *string
|
|
DisplayName string
|
|
Active bool
|
|
}
|
|
|
|
// LocationID tri-state: nil keeps the current assignment, a pointer to an
|
|
// empty string clears it, a pointer to an id reassigns it.
|
|
type UpdateStaffParams struct {
|
|
DisplayName *string
|
|
LocationID *string
|
|
Active *bool
|
|
}
|
|
|
|
type AvailabilityRuleRecord struct {
|
|
ID string
|
|
TenantID string
|
|
StaffID *string
|
|
DayOfWeek int
|
|
StartsLocal string
|
|
EndsLocal string
|
|
}
|
|
|
|
type ClassTemplateRecord struct {
|
|
ID string
|
|
TenantID string
|
|
Title string
|
|
DurationMinutes int
|
|
Capacity int
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateClassTemplateParams struct {
|
|
TenantID string
|
|
Title string
|
|
DurationMinutes int
|
|
Capacity int
|
|
}
|
|
|
|
type ClassSessionRecord struct {
|
|
ID string
|
|
TenantID string
|
|
TemplateID string
|
|
LocationID *string
|
|
Title string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Capacity int32
|
|
}
|
|
|
|
type CreateClassSessionParams struct {
|
|
TenantID string
|
|
TemplateID string
|
|
LocationID *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
|
|
CustomerPhone string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Status string
|
|
Reference string
|
|
ManageToken string
|
|
Notes string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateBookingParams struct {
|
|
TenantID string
|
|
ServiceID *string
|
|
ClassSessionID *string
|
|
StaffID *string
|
|
LocationID *string
|
|
BookingMode string
|
|
CustomerName string
|
|
CustomerEmail string
|
|
CustomerPhone string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Status string
|
|
Reference string
|
|
ManageToken string
|
|
Notes string
|
|
}
|
|
|
|
type CreatedBooking struct {
|
|
ID string
|
|
Reference string
|
|
Status string
|
|
ManageToken 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
|
|
Type string
|
|
Capacity int
|
|
Timezone string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateLocationParams struct {
|
|
TenantID string
|
|
Name string
|
|
Type string
|
|
Capacity int
|
|
Timezone string
|
|
}
|
|
|
|
type UpdateLocationParams struct {
|
|
Name *string
|
|
Type *string
|
|
Capacity *int
|
|
Timezone *string
|
|
}
|
|
|
|
// Blocked Day Records
|
|
type BlockedDayRecord struct {
|
|
ID string
|
|
TenantID string
|
|
StaffID *string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Kind string
|
|
Reason string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type CreateBlockedDayParams struct {
|
|
TenantID string
|
|
StaffID *string
|
|
StartsAt time.Time
|
|
EndsAt time.Time
|
|
Kind string
|
|
Reason string
|
|
}
|
|
|
|
type UpdateBlockedDayParams struct {
|
|
StartsAt *time.Time
|
|
EndsAt *time.Time
|
|
Kind *string
|
|
Reason *string
|
|
}
|
|
|
|
// Customer Records
|
|
type CustomerRecord struct {
|
|
ID string
|
|
TenantID string
|
|
Name string
|
|
Email string
|
|
Phone *string
|
|
Status string
|
|
CreatedAt time.Time
|
|
Notes *string
|
|
}
|
|
|
|
type CreateCustomerParams struct {
|
|
TenantID string
|
|
Name string
|
|
Email string
|
|
Phone *string
|
|
Status string
|
|
Notes *string
|
|
}
|
|
|
|
type UpdateCustomerParams struct {
|
|
Name *string
|
|
Email *string
|
|
Phone *string
|
|
Status *string
|
|
Notes *string
|
|
}
|
|
|
|
// Working Hours Records
|
|
type WorkingHoursRecord struct {
|
|
TenantID string
|
|
StaffID *string
|
|
DayOfWeek int
|
|
StartsLocal string
|
|
EndsLocal string
|
|
}
|
|
|
|
type UpdateWorkingHoursParams struct {
|
|
StartsLocal *string
|
|
EndsLocal *string
|
|
IsOpen *bool
|
|
}
|
|
|
|
// Email Template Records
|
|
type EmailTemplateRecord struct {
|
|
ID string
|
|
TenantID string
|
|
Type string
|
|
Subject string
|
|
BodyHTML string
|
|
BodyText string
|
|
IsEnabled bool
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// SMS Records
|
|
type TenantSMSSettingsRecord struct {
|
|
TenantID string
|
|
Enabled bool
|
|
SenderName string
|
|
MonthlyLimit int
|
|
StripeSubscriptionItemID string
|
|
}
|
|
|
|
type SMSUsageLogRecord struct {
|
|
ID string
|
|
TenantID string
|
|
RecipientPhone string
|
|
MessageBody string
|
|
ExternalMessageID string
|
|
ExternalRequestID string
|
|
Status string
|
|
CostCents int
|
|
SentAt time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type SMSUsageSummary struct {
|
|
MessageCount int
|
|
TotalCostCents int
|
|
}
|
|
|
|
type SMSMonthlyReportRecord struct {
|
|
ID string
|
|
TenantID string
|
|
YearMonth string
|
|
MessageCount int
|
|
TotalCostCents int
|
|
StripeInvoiceID string
|
|
InvoiceSentAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type PGRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewRepository(pools *Pools, demoMode bool) Repository {
|
|
if demoMode {
|
|
return NewMemoryRepository()
|
|
}
|
|
if pools != nil && pools.App != nil {
|
|
return &PGRepository{pool: pools.App}
|
|
}
|
|
return NewMemoryRepository()
|
|
}
|
|
|
|
// ============================================
|
|
// LOCATION / ZONE METHODS - PG REPOSITORY (STUBS)
|
|
// ============================================
|
|
|
|
// ============================================
|
|
// BLOCKED DAYS METHODS - PG REPOSITORY (STUBS)
|
|
// ============================================
|
|
|
|
// ============================================
|
|
// CUSTOMER METHODS - PG REPOSITORY (STUBS)
|
|
// ============================================
|
|
|
|
func (r *PGRepository) ListCustomersByTenant(ctx context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT id, tenant_id, name, email, phone, status, created_at, notes
|
|
FROM customers
|
|
WHERE tenant_id = $1
|
|
ORDER BY name
|
|
LIMIT $2 OFFSET $3
|
|
`, tenantID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []CustomerRecord
|
|
for rows.Next() {
|
|
var rec CustomerRecord
|
|
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, rec)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
func (r *PGRepository) GetCustomerByID(ctx context.Context, customerID string) (CustomerRecord, error) {
|
|
var rec CustomerRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT id, tenant_id, name, email, phone, status, created_at, notes
|
|
FROM customers
|
|
WHERE id = $1
|
|
`, customerID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) GetCustomerByEmail(ctx context.Context, tenantID string, email string) (CustomerRecord, error) {
|
|
var rec CustomerRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT id, tenant_id, name, email, phone, status, created_at, notes
|
|
FROM customers
|
|
WHERE tenant_id = $1 AND email = $2
|
|
`, tenantID, email).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) CreateCustomer(ctx context.Context, params CreateCustomerParams) (CustomerRecord, error) {
|
|
var rec CustomerRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
INSERT INTO customers (tenant_id, name, email, phone, status, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, tenant_id, name, email, phone, status, created_at, notes
|
|
`, params.TenantID, params.Name, params.Email, params.Phone, params.Status, params.Notes).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) UpdateCustomer(ctx context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error) {
|
|
var rec CustomerRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
UPDATE customers
|
|
SET name = COALESCE($2, name),
|
|
email = COALESCE($3, email),
|
|
phone = COALESCE($4, phone),
|
|
status = COALESCE($5, status),
|
|
notes = COALESCE($6, notes)
|
|
WHERE id = $1
|
|
RETURNING id, tenant_id, name, email, phone, status, created_at, notes
|
|
`, customerID, params.Name, params.Email, params.Phone, params.Status, params.Notes).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Email, &rec.Phone, &rec.Status, &rec.CreatedAt, &rec.Notes)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) DeleteCustomer(ctx context.Context, customerID string) error {
|
|
_, err := r.pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, customerID)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) GetCustomerBookingsCount(ctx context.Context, customerID string) (int, error) {
|
|
var count int
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE customer_email = (SELECT email FROM customers WHERE id = $1)
|
|
`, customerID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (r *PGRepository) GetCustomerLastBooking(ctx context.Context, customerID string) (*time.Time, error) {
|
|
var lastTime *time.Time
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT MAX(starts_at) FROM bookings
|
|
WHERE customer_email = (SELECT email FROM customers WHERE id = $1)
|
|
`, customerID).Scan(&lastTime)
|
|
return lastTime, err
|
|
}
|
|
|
|
// ============================================
|
|
// BOOKING MANAGEMENT METHODS - PG REPOSITORY (STUBS)
|
|
// ============================================
|
|
|
|
func (r *PGRepository) GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) {
|
|
var rec BookingRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
|
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
|
FROM bookings
|
|
WHERE reference = $1
|
|
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
|
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
|
|
var rec BookingRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
|
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
|
FROM bookings
|
|
WHERE id = $1
|
|
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
|
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
|
return rec, err
|
|
}
|
|
|
|
func (r *PGRepository) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE bookings SET status = $2, updated_at = now() WHERE id = $1
|
|
`, bookingID, status)
|
|
return err
|
|
}
|
|
|
|
func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE bookings SET starts_at = $2, ends_at = $3, updated_at = now() WHERE id = $1
|
|
`, bookingID, startsAt, endsAt)
|
|
return err
|
|
}
|
|
|
|
// ============================================
|
|
// WORKING HOURS METHODS - PG REPOSITORY (STUBS)
|
|
// ============================================
|
|
|
|
type MemoryRepository struct {
|
|
tenant TenantRecord
|
|
membership TenantMembershipRecord
|
|
brand BrandProfileRecord
|
|
services []ServiceRecord
|
|
rules []AvailabilityRuleRecord
|
|
classTemplates []ClassTemplateRecord
|
|
classSessions []ClassSessionRecord
|
|
bookings []BookingRecord
|
|
waitlistSize int
|
|
reminderJobs []ReminderJobRecord
|
|
deliveryLogs []NotificationDeliveryLogParams
|
|
billingSnapshot BillingSnapshotRecord
|
|
recordedEvents map[string]struct{}
|
|
locations []LocationRecord
|
|
staff []StaffRecord
|
|
blockedDays []BlockedDayRecord
|
|
customers []CustomerRecord
|
|
workingHours []WorkingHoursRecord
|
|
smsSettings TenantSMSSettingsRecord
|
|
smsLogs []SMSUsageLogRecord
|
|
smsReports []SMSMonthlyReportRecord
|
|
emailTemplates []EmailTemplateRecord
|
|
}
|
|
|
|
func NewMemoryRepository() *MemoryRepository {
|
|
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
|
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
|
|
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
|
|
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
|
|
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
|
|
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
|
now := time.Now().UTC()
|
|
|
|
return &MemoryRepository{
|
|
tenant: TenantRecord{
|
|
ID: tenantID,
|
|
Slug: "studio-atelier",
|
|
Name: "Studio Atelier",
|
|
Preset: "studio",
|
|
Locale: "cs",
|
|
Timezone: "Europe/Prague",
|
|
PlanCode: "pro",
|
|
SubscriptionStatus: "active",
|
|
BillingProvider: "stripe",
|
|
BillingCustomerID: stringPtr("ctm_demo_bookra"),
|
|
},
|
|
membership: TenantMembershipRecord{
|
|
Tenant: TenantRecord{
|
|
ID: tenantID,
|
|
Slug: "studio-atelier",
|
|
Name: "Studio Atelier",
|
|
Preset: "studio",
|
|
Locale: "cs",
|
|
Timezone: "Europe/Prague",
|
|
PlanCode: "pro",
|
|
SubscriptionStatus: "active",
|
|
BillingProvider: "stripe",
|
|
BillingCustomerID: stringPtr("ctm_demo_bookra"),
|
|
},
|
|
UserID: "demo-owner",
|
|
Role: "owner",
|
|
},
|
|
brand: BrandProfileRecord{
|
|
TenantID: tenantID,
|
|
Name: "Studio Atelier",
|
|
SiteURL: "https://studio-atelier.example",
|
|
PrimaryColor: "#a65c3e",
|
|
},
|
|
services: []ServiceRecord{
|
|
{
|
|
ID: serviceID,
|
|
TenantID: tenantID,
|
|
Name: "Signature treatment",
|
|
DurationMinutes: 60,
|
|
BufferBeforeMinutes: 0,
|
|
BufferAfterMinutes: 15,
|
|
PriceCents: 120000,
|
|
CreatedAt: now,
|
|
},
|
|
},
|
|
staff: []StaffRecord{
|
|
{
|
|
ID: staffID,
|
|
TenantID: tenantID,
|
|
LocationID: &locationID,
|
|
DisplayName: "Demo Stylist",
|
|
Active: true,
|
|
CreatedAt: now,
|
|
},
|
|
},
|
|
rules: []AvailabilityRuleRecord{
|
|
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 1, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
|
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 2, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
|
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 3, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
|
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
|
{ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"},
|
|
},
|
|
classTemplates: []ClassTemplateRecord{
|
|
{ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now},
|
|
},
|
|
classSessions: []ClassSessionRecord{
|
|
{
|
|
ID: sessionID,
|
|
TenantID: tenantID,
|
|
TemplateID: templateID,
|
|
LocationID: &locationID,
|
|
Title: "Small group mobility class",
|
|
StartsAt: now.Add(48 * time.Hour),
|
|
EndsAt: now.Add(49 * time.Hour),
|
|
Capacity: 4,
|
|
},
|
|
},
|
|
bookings: []BookingRecord{},
|
|
reminderJobs: []ReminderJobRecord{},
|
|
deliveryLogs: []NotificationDeliveryLogParams{},
|
|
billingSnapshot: BillingSnapshotRecord{
|
|
TenantID: tenantID,
|
|
BillingProvider: "stripe",
|
|
BillingCustomerID: "ctm_demo_bookra",
|
|
BillingSubscriptionID: "",
|
|
Status: "active",
|
|
PlanCode: "pro",
|
|
Currency: "czk",
|
|
PriceID: "",
|
|
},
|
|
recordedEvents: map[string]struct{}{},
|
|
locations: []LocationRecord{
|
|
{ID: locationID, TenantID: tenantID, Name: "Main Studio", Timezone: "Europe/Prague", CreatedAt: now},
|
|
},
|
|
blockedDays: []BlockedDayRecord{},
|
|
customers: []CustomerRecord{
|
|
{ID: uuid.NewString(), TenantID: tenantID, Name: "Alice Johnson", Email: "[email protected]", Phone: stringPtr("+420123456789"), Status: "vip", CreatedAt: now},
|
|
{ID: uuid.NewString(), TenantID: tenantID, Name: "Bob Smith", Email: "[email protected]", Status: "active", CreatedAt: now},
|
|
{ID: uuid.NewString(), TenantID: tenantID, Name: "Carol White", Email: "[email protected]", Status: "active", CreatedAt: now},
|
|
},
|
|
workingHours: []WorkingHoursRecord{
|
|
{TenantID: tenantID, DayOfWeek: 1, StartsLocal: "09:00", EndsLocal: "17:00"},
|
|
{TenantID: tenantID, DayOfWeek: 2, StartsLocal: "09:00", EndsLocal: "17:00"},
|
|
{TenantID: tenantID, DayOfWeek: 3, StartsLocal: "09:00", EndsLocal: "17:00"},
|
|
{TenantID: tenantID, DayOfWeek: 4, StartsLocal: "09:00", EndsLocal: "17:00"},
|
|
{TenantID: tenantID, DayOfWeek: 5, StartsLocal: "09:00", EndsLocal: "17:00"},
|
|
{TenantID: tenantID, DayOfWeek: 6, StartsLocal: "10:00", EndsLocal: "14:00"},
|
|
{TenantID: tenantID, DayOfWeek: 0, StartsLocal: "", EndsLocal: ""},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *MemoryRepository) GetTenantBySlug(_ context.Context, slug string) (TenantRecord, error) {
|
|
if slug != r.tenant.Slug {
|
|
return TenantRecord{}, pgx.ErrNoRows
|
|
}
|
|
return r.tenant, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetTenantByID(_ context.Context, tenantID string) (TenantRecord, error) {
|
|
if tenantID == r.tenant.ID {
|
|
return r.tenant, nil
|
|
}
|
|
return TenantRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) GetTenantByBillingCustomerID(_ context.Context, customerID string) (TenantRecord, error) {
|
|
if r.billingSnapshot.BillingCustomerID == customerID {
|
|
return r.tenant, nil
|
|
}
|
|
return TenantRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) EnsureUserIdentity(_ context.Context, subject string, email string, displayName string) error {
|
|
if strings.TrimSpace(subject) == "" {
|
|
return nil
|
|
}
|
|
r.membership.UserID = subject
|
|
_ = email
|
|
_ = displayName
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error) {
|
|
tenantID := uuid.NewString()
|
|
r.tenant = TenantRecord{
|
|
ID: tenantID,
|
|
Slug: params.Slug,
|
|
Name: params.Name,
|
|
Preset: params.Preset,
|
|
Locale: params.Locale,
|
|
Timezone: params.Timezone,
|
|
PlanCode: "starter",
|
|
SubscriptionStatus: "trialing",
|
|
}
|
|
r.membership = TenantMembershipRecord{
|
|
Tenant: r.tenant,
|
|
UserID: params.Subject,
|
|
Role: "owner",
|
|
}
|
|
r.brand = BrandProfileRecord{
|
|
TenantID: tenantID,
|
|
Name: firstNonEmpty(params.BrandName, params.Name),
|
|
SiteURL: params.SiteURL,
|
|
LogoURL: params.LogoURL,
|
|
PrimaryColor: params.PrimaryColor,
|
|
}
|
|
serviceName := firstNonEmpty(params.ServiceName, "First appointment")
|
|
duration := params.DurationMinutes
|
|
if duration <= 0 {
|
|
duration = 60
|
|
}
|
|
r.services = []ServiceRecord{{
|
|
ID: uuid.NewString(),
|
|
TenantID: tenantID,
|
|
Name: serviceName,
|
|
DurationMinutes: duration,
|
|
BufferBeforeMinutes: maxInt(params.BufferBeforeMinutes, 0),
|
|
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
|
|
PriceCents: 0,
|
|
}}
|
|
r.staff = nil
|
|
r.rules = nil
|
|
blocks := params.AvailabilityBlocks
|
|
if len(blocks) == 0 {
|
|
blocks = defaultAvailabilityBlocks()
|
|
}
|
|
for _, block := range blocks {
|
|
if block.Busy {
|
|
continue
|
|
}
|
|
r.rules = append(r.rules, AvailabilityRuleRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: tenantID,
|
|
DayOfWeek: block.DayOfWeek,
|
|
StartsLocal: block.StartsLocal,
|
|
EndsLocal: block.EndsLocal,
|
|
})
|
|
}
|
|
r.classSessions = nil
|
|
r.bookings = nil
|
|
r.reminderJobs = nil
|
|
return r.membership, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
|
|
if subject == "" || subject == "demo-owner" {
|
|
return r.membership, nil
|
|
}
|
|
if subject == r.membership.UserID {
|
|
return r.membership, nil
|
|
}
|
|
return TenantMembershipRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) IsAdminSubject(_ context.Context, subject string) (bool, error) {
|
|
return subject == "demo-owner" || subject == r.membership.UserID, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetTenantOwnerEmail(_ context.Context, tenantID string) (string, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return "", pgx.ErrNoRows
|
|
}
|
|
return "[email protected]", nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateBrandProfile(_ context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return BrandProfileRecord{}, pgx.ErrNoRows
|
|
}
|
|
if params.Name != nil {
|
|
r.brand.Name = *params.Name
|
|
}
|
|
if params.SiteURL != nil {
|
|
r.brand.SiteURL = *params.SiteURL
|
|
}
|
|
if params.LogoURL != nil {
|
|
r.brand.LogoURL = *params.LogoURL
|
|
}
|
|
if params.PrimaryColor != nil {
|
|
r.brand.PrimaryColor = *params.PrimaryColor
|
|
}
|
|
return r.brand, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetBrandProfile(_ context.Context, tenantID string) (BrandProfileRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return BrandProfileRecord{}, pgx.ErrNoRows
|
|
}
|
|
if r.brand.Name == "" {
|
|
r.brand = BrandProfileRecord{TenantID: tenantID, Name: r.tenant.Name}
|
|
}
|
|
return r.brand, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID string) ([]ServiceRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, nil
|
|
}
|
|
return append([]ServiceRecord(nil), r.services...), nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
|
|
for _, service := range r.services {
|
|
if service.ID == serviceID {
|
|
return service, nil
|
|
}
|
|
}
|
|
return ServiceRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
|
|
service := ServiceRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
Name: params.Name,
|
|
DurationMinutes: params.DurationMinutes,
|
|
BufferBeforeMinutes: params.BufferBeforeMinutes,
|
|
BufferAfterMinutes: params.BufferAfterMinutes,
|
|
PriceCents: params.PriceCents,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
r.services = append(r.services, service)
|
|
return service, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
|
|
for i, service := range r.services {
|
|
if service.ID != serviceID {
|
|
continue
|
|
}
|
|
if params.Name != nil {
|
|
service.Name = *params.Name
|
|
}
|
|
if params.DurationMinutes != nil {
|
|
service.DurationMinutes = *params.DurationMinutes
|
|
}
|
|
if params.BufferBeforeMinutes != nil {
|
|
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
|
|
}
|
|
if params.BufferAfterMinutes != nil {
|
|
service.BufferAfterMinutes = *params.BufferAfterMinutes
|
|
}
|
|
if params.PriceCents != nil {
|
|
service.PriceCents = *params.PriceCents
|
|
}
|
|
r.services[i] = service
|
|
return service, nil
|
|
}
|
|
return ServiceRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
|
|
for i, service := range r.services {
|
|
if service.ID != serviceID {
|
|
continue
|
|
}
|
|
r.services = append(r.services[:i], r.services[i+1:]...)
|
|
// Mirror ON DELETE SET NULL for bookings referencing the service.
|
|
for j, booking := range r.bookings {
|
|
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
|
|
booking.ServiceID = nil
|
|
r.bookings[j] = booking
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, nil
|
|
}
|
|
return append([]StaffRecord(nil), r.staff...), nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
|
|
for _, member := range r.staff {
|
|
if member.ID == staffID {
|
|
return member, nil
|
|
}
|
|
}
|
|
return StaffRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
|
|
member := StaffRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
LocationID: params.LocationID,
|
|
DisplayName: params.DisplayName,
|
|
Active: params.Active,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
r.staff = append(r.staff, member)
|
|
return member, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
|
|
for i, member := range r.staff {
|
|
if member.ID != staffID {
|
|
continue
|
|
}
|
|
if params.DisplayName != nil {
|
|
member.DisplayName = *params.DisplayName
|
|
}
|
|
if params.LocationID != nil {
|
|
locationID := strings.TrimSpace(*params.LocationID)
|
|
if locationID == "" {
|
|
member.LocationID = nil
|
|
} else {
|
|
member.LocationID = &locationID
|
|
}
|
|
}
|
|
if params.Active != nil {
|
|
member.Active = *params.Active
|
|
}
|
|
r.staff[i] = member
|
|
return member, nil
|
|
}
|
|
return StaffRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
|
|
for i, member := range r.staff {
|
|
if member.ID != staffID {
|
|
continue
|
|
}
|
|
r.staff = append(r.staff[:i], r.staff[i+1:]...)
|
|
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
|
|
for j, booking := range r.bookings {
|
|
if booking.StaffID != nil && *booking.StaffID == staffID {
|
|
booking.StaffID = nil
|
|
r.bookings[j] = booking
|
|
}
|
|
}
|
|
var rules []AvailabilityRuleRecord
|
|
for _, rule := range r.rules {
|
|
if rule.StaffID == nil || *rule.StaffID != staffID {
|
|
rules = append(rules, rule)
|
|
}
|
|
}
|
|
r.rules = rules
|
|
return nil
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, nil
|
|
}
|
|
return append([]AvailabilityRuleRecord(nil), r.rules...), nil
|
|
}
|
|
|
|
func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, nil
|
|
}
|
|
var out []ClassSessionRecord
|
|
for _, session := range r.classSessions {
|
|
if session.StartsAt.Before(from) {
|
|
continue
|
|
}
|
|
out = append(out, session)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) {
|
|
for _, session := range r.classSessions {
|
|
if session.ID == sessionID {
|
|
return session, nil
|
|
}
|
|
}
|
|
return ClassSessionRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
|
|
record := ClassSessionRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
TemplateID: params.TemplateID,
|
|
LocationID: params.LocationID,
|
|
StartsAt: params.StartsAt,
|
|
EndsAt: params.EndsAt,
|
|
Capacity: params.Capacity,
|
|
}
|
|
for _, template := range r.classTemplates {
|
|
if template.ID == params.TemplateID {
|
|
record.Title = template.Title
|
|
break
|
|
}
|
|
}
|
|
r.classSessions = append(r.classSessions, record)
|
|
return record, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error {
|
|
for i, session := range r.classSessions {
|
|
if session.ID == sessionID {
|
|
r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...)
|
|
for j := range r.bookings {
|
|
if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID {
|
|
r.bookings[j].ClassSessionID = nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, nil
|
|
}
|
|
return append([]ClassTemplateRecord(nil), r.classTemplates...), nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) {
|
|
for _, template := range r.classTemplates {
|
|
if template.ID == templateID {
|
|
return template, nil
|
|
}
|
|
}
|
|
return ClassTemplateRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
|
record := ClassTemplateRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
Title: params.Title,
|
|
DurationMinutes: params.DurationMinutes,
|
|
Capacity: params.Capacity,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
r.classTemplates = append(r.classTemplates, record)
|
|
return record, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
|
|
for i, template := range r.classTemplates {
|
|
if template.ID == templateID {
|
|
r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...)
|
|
// class_sessions.template_id has ON DELETE CASCADE
|
|
var remaining []ClassSessionRecord
|
|
for _, session := range r.classSessions {
|
|
if session.TemplateID != templateID {
|
|
remaining = append(remaining, session)
|
|
}
|
|
}
|
|
r.classSessions = remaining
|
|
return nil
|
|
}
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) {
|
|
var out []BookingRecord
|
|
for _, booking := range r.bookings {
|
|
if booking.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if booking.StartsAt.Before(to) && booking.EndsAt.After(from) {
|
|
out = append(out, booking)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
|
created := CreatedBooking{
|
|
ID: uuid.NewString(),
|
|
Reference: params.Reference,
|
|
Status: params.Status,
|
|
ManageToken: params.ManageToken,
|
|
}
|
|
r.bookings = append(r.bookings, BookingRecord{
|
|
ID: created.ID,
|
|
TenantID: params.TenantID,
|
|
ServiceID: params.ServiceID,
|
|
ClassSessionID: params.ClassSessionID,
|
|
StaffID: params.StaffID,
|
|
LocationID: params.LocationID,
|
|
CustomerName: params.CustomerName,
|
|
CustomerEmail: params.CustomerEmail,
|
|
CustomerPhone: params.CustomerPhone,
|
|
StartsAt: params.StartsAt,
|
|
EndsAt: params.EndsAt,
|
|
Status: params.Status,
|
|
Reference: params.Reference,
|
|
ManageToken: params.ManageToken,
|
|
Notes: params.Notes,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
return created, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
|
|
r.waitlistSize++
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
|
|
if r.waitlistSize > 0 {
|
|
r.waitlistSize--
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
|
|
reference := params.BookingID
|
|
startsAt := time.Now().UTC()
|
|
customerName := "Booking Customer"
|
|
customerEmail := "[email protected]"
|
|
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, provider string, customerID string) error {
|
|
if tenantID != r.tenant.ID {
|
|
return pgx.ErrNoRows
|
|
}
|
|
r.tenant.BillingProvider = provider
|
|
r.tenant.BillingCustomerID = &customerID
|
|
r.membership.Tenant.BillingProvider = provider
|
|
r.membership.Tenant.BillingCustomerID = &customerID
|
|
r.billingSnapshot.BillingProvider = provider
|
|
r.billingSnapshot.BillingCustomerID = customerID
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, provider string, planCode string, subscriptionStatus string, subscriptionID string) error {
|
|
if tenantID != r.tenant.ID {
|
|
return pgx.ErrNoRows
|
|
}
|
|
r.tenant.PlanCode = planCode
|
|
r.tenant.SubscriptionStatus = subscriptionStatus
|
|
r.tenant.BillingProvider = provider
|
|
r.membership.Tenant.PlanCode = planCode
|
|
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
|
|
r.membership.Tenant.BillingProvider = provider
|
|
r.billingSnapshot.PlanCode = planCode
|
|
r.billingSnapshot.Status = subscriptionStatus
|
|
r.billingSnapshot.BillingProvider = provider
|
|
r.billingSnapshot.BillingSubscriptionID = subscriptionID
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) RecordBillingEvent(_ context.Context, _ string, provider string, eventID string, _ string, payload []byte) (bool, error) {
|
|
key := firstNonEmpty(provider, "stripe") + ":" + eventID
|
|
if _, exists := r.recordedEvents[key]; exists {
|
|
return false, nil
|
|
}
|
|
if len(payload) > 0 {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
r.recordedEvents[key] = struct{}{}
|
|
return true, nil
|
|
}
|
|
|
|
// ============================================
|
|
// LOCATION / ZONE METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
func (r *MemoryRepository) ListLocationsByTenant(_ context.Context, tenantID string) ([]LocationRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, pgx.ErrNoRows
|
|
}
|
|
return r.locations, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetLocationByID(_ context.Context, locationID string) (LocationRecord, error) {
|
|
for _, loc := range r.locations {
|
|
if loc.ID == locationID {
|
|
return loc, nil
|
|
}
|
|
}
|
|
return LocationRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocationParams) (LocationRecord, error) {
|
|
loc := LocationRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
Name: params.Name,
|
|
Type: params.Type,
|
|
Capacity: params.Capacity,
|
|
Timezone: params.Timezone,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
r.locations = append(r.locations, loc)
|
|
return loc, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) {
|
|
for i, loc := range r.locations {
|
|
if loc.ID == locationID {
|
|
if params.Name != nil {
|
|
r.locations[i].Name = *params.Name
|
|
}
|
|
if params.Type != nil {
|
|
r.locations[i].Type = *params.Type
|
|
}
|
|
if params.Capacity != nil {
|
|
r.locations[i].Capacity = *params.Capacity
|
|
}
|
|
if params.Timezone != nil {
|
|
r.locations[i].Timezone = *params.Timezone
|
|
}
|
|
return r.locations[i], nil
|
|
}
|
|
}
|
|
return LocationRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteLocation(_ context.Context, locationID string) error {
|
|
for i, loc := range r.locations {
|
|
if loc.ID == locationID {
|
|
r.locations = append(r.locations[:i], r.locations[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
// ============================================
|
|
// BLOCKED DAYS METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
func (r *MemoryRepository) ListBlockedDaysByTenant(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, pgx.ErrNoRows
|
|
}
|
|
var out []BlockedDayRecord
|
|
for _, bd := range r.blockedDays {
|
|
if bd.TenantID == tenantID && !bd.StartsAt.After(to) && !bd.EndsAt.Before(from) {
|
|
out = append(out, bd)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateBlockedDay(_ context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) {
|
|
bd := BlockedDayRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
StaffID: params.StaffID,
|
|
StartsAt: params.StartsAt,
|
|
EndsAt: params.EndsAt,
|
|
Kind: params.Kind,
|
|
Reason: params.Reason,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
r.blockedDays = append(r.blockedDays, bd)
|
|
return bd, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateBlockedDay(_ context.Context, blockedDayID string, params UpdateBlockedDayParams) (BlockedDayRecord, error) {
|
|
for i, bd := range r.blockedDays {
|
|
if bd.ID == blockedDayID {
|
|
if params.StartsAt != nil {
|
|
r.blockedDays[i].StartsAt = *params.StartsAt
|
|
}
|
|
if params.EndsAt != nil {
|
|
r.blockedDays[i].EndsAt = *params.EndsAt
|
|
}
|
|
if params.Kind != nil {
|
|
r.blockedDays[i].Kind = *params.Kind
|
|
}
|
|
if params.Reason != nil {
|
|
r.blockedDays[i].Reason = *params.Reason
|
|
}
|
|
return r.blockedDays[i], nil
|
|
}
|
|
}
|
|
return BlockedDayRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteBlockedDay(_ context.Context, blockedDayID string) error {
|
|
for i, bd := range r.blockedDays {
|
|
if bd.ID == blockedDayID {
|
|
r.blockedDays = append(r.blockedDays[:i], r.blockedDays[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
// ============================================
|
|
// CUSTOMER METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
func (r *MemoryRepository) ListCustomersByTenant(_ context.Context, tenantID string, limit int, offset int) ([]CustomerRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, pgx.ErrNoRows
|
|
}
|
|
start := offset
|
|
if start > len(r.customers) {
|
|
return []CustomerRecord{}, nil
|
|
}
|
|
end := start + limit
|
|
if end > len(r.customers) {
|
|
end = len(r.customers)
|
|
}
|
|
return r.customers[start:end], nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetCustomerByID(_ context.Context, customerID string) (CustomerRecord, error) {
|
|
for _, c := range r.customers {
|
|
if c.ID == customerID {
|
|
return c, nil
|
|
}
|
|
}
|
|
return CustomerRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) GetCustomerByEmail(_ context.Context, tenantID string, email string) (CustomerRecord, error) {
|
|
for _, c := range r.customers {
|
|
if c.TenantID == tenantID && c.Email == email {
|
|
return c, nil
|
|
}
|
|
}
|
|
return CustomerRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateCustomer(_ context.Context, params CreateCustomerParams) (CustomerRecord, error) {
|
|
c := CustomerRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
Name: params.Name,
|
|
Email: params.Email,
|
|
Phone: params.Phone,
|
|
Status: params.Status,
|
|
CreatedAt: time.Now(),
|
|
Notes: params.Notes,
|
|
}
|
|
r.customers = append(r.customers, c)
|
|
return c, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateCustomer(_ context.Context, customerID string, params UpdateCustomerParams) (CustomerRecord, error) {
|
|
for i, c := range r.customers {
|
|
if c.ID == customerID {
|
|
if params.Name != nil {
|
|
r.customers[i].Name = *params.Name
|
|
}
|
|
if params.Email != nil {
|
|
r.customers[i].Email = *params.Email
|
|
}
|
|
if params.Phone != nil {
|
|
r.customers[i].Phone = params.Phone
|
|
}
|
|
if params.Status != nil {
|
|
r.customers[i].Status = *params.Status
|
|
}
|
|
if params.Notes != nil {
|
|
r.customers[i].Notes = params.Notes
|
|
}
|
|
return r.customers[i], nil
|
|
}
|
|
}
|
|
return CustomerRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) DeleteCustomer(_ context.Context, customerID string) error {
|
|
for i, c := range r.customers {
|
|
if c.ID == customerID {
|
|
r.customers = append(r.customers[:i], r.customers[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) GetCustomerBookingsCount(_ context.Context, customerID string) (int, error) {
|
|
count := 0
|
|
for _, b := range r.bookings {
|
|
for _, c := range r.customers {
|
|
if c.ID == customerID && b.CustomerEmail == c.Email {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetCustomerLastBooking(_ context.Context, customerID string) (*time.Time, error) {
|
|
var lastTime *time.Time
|
|
var customerEmail string
|
|
for _, c := range r.customers {
|
|
if c.ID == customerID {
|
|
customerEmail = c.Email
|
|
break
|
|
}
|
|
}
|
|
if customerEmail == "" {
|
|
return nil, nil
|
|
}
|
|
for _, b := range r.bookings {
|
|
if b.CustomerEmail == customerEmail {
|
|
if lastTime == nil || b.StartsAt.After(*lastTime) {
|
|
lastTime = &b.StartsAt
|
|
}
|
|
}
|
|
}
|
|
return lastTime, nil
|
|
}
|
|
|
|
// ============================================
|
|
// BOOKING MANAGEMENT METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference string) (BookingRecord, error) {
|
|
for _, b := range r.bookings {
|
|
if b.Reference == reference {
|
|
return b, nil
|
|
}
|
|
}
|
|
return BookingRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
|
|
for _, b := range r.bookings {
|
|
if b.ID == bookingID {
|
|
return b, nil
|
|
}
|
|
}
|
|
return BookingRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) 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
|
|
}
|
|
|
|
// ============================================
|
|
// EMAIL TEMPLATE METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
func (r *MemoryRepository) ListEmailTemplates(_ context.Context, tenantID string) ([]EmailTemplateRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, pgx.ErrNoRows
|
|
}
|
|
return r.emailTemplates, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetEmailTemplate(_ context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) {
|
|
for _, t := range r.emailTemplates {
|
|
if t.TenantID == tenantID && t.Type == templateType {
|
|
return t, nil
|
|
}
|
|
}
|
|
return EmailTemplateRecord{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) {
|
|
for i, t := range r.emailTemplates {
|
|
if t.TenantID == params.TenantID && t.Type == params.Type {
|
|
r.emailTemplates[i].Subject = params.Subject
|
|
r.emailTemplates[i].BodyHTML = params.BodyHTML
|
|
r.emailTemplates[i].BodyText = params.BodyText
|
|
r.emailTemplates[i].IsEnabled = params.IsEnabled
|
|
r.emailTemplates[i].UpdatedAt = time.Now()
|
|
return r.emailTemplates[i], nil
|
|
}
|
|
}
|
|
rec := EmailTemplateRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: params.TenantID,
|
|
Type: params.Type,
|
|
Subject: params.Subject,
|
|
BodyHTML: params.BodyHTML,
|
|
BodyText: params.BodyText,
|
|
IsEnabled: params.IsEnabled,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
r.emailTemplates = append(r.emailTemplates, rec)
|
|
return rec, nil
|
|
}
|
|
|
|
// ============================================
|
|
// WORKING HOURS METHODS - MEMORY REPOSITORY
|
|
// ============================================
|
|
|
|
// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated
|
|
// working-hours store, and staff-scoped hours from the availability rules that
|
|
// slot generation consumes — mirroring PostgreSQL where both live in
|
|
// availability_rules.
|
|
func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) {
|
|
if tenantID != r.tenant.ID {
|
|
return nil, pgx.ErrNoRows
|
|
}
|
|
var out []WorkingHoursRecord
|
|
if staffID == nil {
|
|
for _, wh := range r.workingHours {
|
|
if wh.TenantID == tenantID && wh.StaffID == nil {
|
|
out = append(out, wh)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
for _, rule := range r.rules {
|
|
if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID {
|
|
out = append(out, WorkingHoursRecord{
|
|
TenantID: rule.TenantID,
|
|
StaffID: rule.StaffID,
|
|
DayOfWeek: rule.DayOfWeek,
|
|
StartsLocal: rule.StartsLocal,
|
|
EndsLocal: rule.EndsLocal,
|
|
})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func staffIDPtrEqual(a, b *string) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
|
if tenantID != r.tenant.ID {
|
|
return pgx.ErrNoRows
|
|
}
|
|
if staffID != nil {
|
|
return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params)
|
|
}
|
|
if params.IsOpen != nil && !*params.IsOpen {
|
|
filtered := r.workingHours[:0]
|
|
for _, wh := range r.workingHours {
|
|
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
|
|
continue
|
|
}
|
|
filtered = append(filtered, wh)
|
|
}
|
|
r.workingHours = filtered
|
|
return nil
|
|
}
|
|
if params.StartsLocal == nil || params.EndsLocal == nil {
|
|
return nil
|
|
}
|
|
for i, wh := range r.workingHours {
|
|
if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil {
|
|
r.workingHours[i].StartsLocal = *params.StartsLocal
|
|
r.workingHours[i].EndsLocal = *params.EndsLocal
|
|
return nil
|
|
}
|
|
}
|
|
r.workingHours = append(r.workingHours, WorkingHoursRecord{
|
|
TenantID: tenantID,
|
|
DayOfWeek: dayOfWeek,
|
|
StartsLocal: *params.StartsLocal,
|
|
EndsLocal: *params.EndsLocal,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// updateStaffWorkingHours replaces the staff-scoped availability rule for a
|
|
// day so edits actually change generated slots, matching PostgreSQL semantics.
|
|
func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error {
|
|
filtered := r.rules[:0]
|
|
for _, rule := range r.rules {
|
|
if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID {
|
|
continue
|
|
}
|
|
filtered = append(filtered, rule)
|
|
}
|
|
r.rules = filtered
|
|
if params.IsOpen != nil && !*params.IsOpen {
|
|
return nil
|
|
}
|
|
if params.StartsLocal == nil || params.EndsLocal == nil {
|
|
return nil
|
|
}
|
|
r.rules = append(r.rules, AvailabilityRuleRecord{
|
|
ID: uuid.NewString(),
|
|
TenantID: tenantID,
|
|
StaffID: staffID,
|
|
DayOfWeek: dayOfWeek,
|
|
StartsLocal: normalizeLocalTime(*params.StartsLocal),
|
|
EndsLocal: normalizeLocalTime(*params.EndsLocal),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func normalizeLocalTime(value string) string {
|
|
if len(value) == 5 {
|
|
return value + ":00"
|
|
}
|
|
return value
|
|
}
|
|
|
|
// Auth methods for MemoryRepository
|
|
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
|
|
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
|
|
return nil
|
|
}
|
|
|
|
// Admin methods for MemoryRepository
|
|
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
|
|
return []TenantRecord{r.tenant}, 1, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
|
|
return []UserRecord{}, 0, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
|
|
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
|
|
}
|
|
|
|
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
|
|
return nil
|
|
}
|
|
|
|
func Reference(prefix string, at time.Time) string {
|
|
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
|
|
}
|
|
|
|
func stringPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func maxInt(left int, right int) int {
|
|
if left > right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func defaultAvailabilityBlocks() []AvailabilityBlockRecord {
|
|
blocks := make([]AvailabilityBlockRecord, 0, 5)
|
|
for day := 1; day <= 5; day++ {
|
|
blocks = append(blocks, AvailabilityBlockRecord{
|
|
DayOfWeek: day,
|
|
StartsLocal: "09:00:00",
|
|
EndsLocal: "17:00:00",
|
|
})
|
|
}
|
|
return blocks
|
|
}
|