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