mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2095 lines
64 KiB
Go
2095 lines
64 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
|
|
CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error
|
|
DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) 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
|
|
CompletedCount 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
|
|
BookingStatus 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) {
|
|
for _, booking := range r.bookings {
|
|
if booking.ID == job.BookingID {
|
|
job.BookingStatus = booking.Status
|
|
break
|
|
}
|
|
}
|
|
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) CancelPendingReminderJobsForBooking(_ context.Context, bookingID string) error {
|
|
for index := range r.reminderJobs {
|
|
if r.reminderJobs[index].BookingID == bookingID && r.reminderJobs[index].Status == "pending" {
|
|
r.reminderJobs[index].Status = "cancelled"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *MemoryRepository) DeletePendingReminderJobsForBooking(_ context.Context, bookingID string) error {
|
|
kept := r.reminderJobs[:0]
|
|
for _, job := range r.reminderJobs {
|
|
if job.BookingID == bookingID && job.Status == "pending" {
|
|
continue
|
|
}
|
|
kept = append(kept, job)
|
|
}
|
|
r.reminderJobs = kept
|
|
return nil
|
|
}
|
|
|
|
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 booking.EndsAt.Before(time.Now().UTC()) {
|
|
metrics.CompletedCount++
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|