refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

+3 -3
View File
@@ -1,7 +1,7 @@
package db
import (
"context"
"context"
)
func (r *PGRepository) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) {
@@ -53,7 +53,7 @@ func (r *PGRepository) UpsertSubscriptionSnapshot(ctx context.Context, params Bi
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,
`, params.TenantID, firstNonEmpty(params.BillingProvider, "stripe"), 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
@@ -64,7 +64,7 @@ func (r *PGRepository) RecordBillingEvent(ctx context.Context, tenantID string,
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)
`, tenantID, firstNonEmpty(provider, "stripe"), eventID, eventType, payload)
if err != nil {
return false, err
}
+9 -5
View File
@@ -8,7 +8,8 @@ import (
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, customer_phone, starts_at, ends_at, status, reference
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -35,6 +36,8 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.EndsAt,
&record.Status,
&record.Reference,
&record.ManageToken,
&record.Notes,
); err != nil {
return nil, err
}
@@ -49,10 +52,10 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
INSERT INTO bookings (
tenant_id, service_id, class_session_id, staff_id, location_id,
booking_mode, customer_name, customer_email, customer_phone, starts_at, ends_at,
status, reference, notes
status, reference, manage_token, notes
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, reference, status
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id, reference, status, manage_token
`,
params.TenantID,
params.ServiceID,
@@ -67,8 +70,9 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
params.EndsAt,
params.Status,
params.Reference,
params.ManageToken,
params.Notes,
).Scan(&created.ID, &created.Reference, &created.Status)
).Scan(&created.ID, &created.Reference, &created.Status, &created.ManageToken)
return created, err
}
@@ -0,0 +1,55 @@
package db
import (
"context"
)
func (r *PGRepository) ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
FROM email_templates
WHERE tenant_id = $1
ORDER BY type
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []EmailTemplateRecord
for rows.Next() {
var rec EmailTemplateRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetEmailTemplate(ctx context.Context, tenantID string, templateType string) (EmailTemplateRecord, error) {
var rec EmailTemplateRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
FROM email_templates
WHERE tenant_id = $1 AND type = $2
`, tenantID, templateType).Scan(&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt)
return rec, err
}
func (r *PGRepository) UpsertEmailTemplate(ctx context.Context, params EmailTemplateRecord) (EmailTemplateRecord, error) {
var rec EmailTemplateRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO email_templates (tenant_id, type, subject, body_html, body_text, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tenant_id, type) DO UPDATE SET
subject = EXCLUDED.subject,
body_html = EXCLUDED.body_html,
body_text = EXCLUDED.body_text,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()
RETURNING id, tenant_id, type, subject, body_html, body_text, is_enabled, updated_at
`, params.TenantID, params.Type, params.Subject, params.BodyHTML, params.BodyText, params.IsEnabled).Scan(
&rec.ID, &rec.TenantID, &rec.Type, &rec.Subject, &rec.BodyHTML, &rec.BodyText, &rec.IsEnabled, &rec.UpdatedAt)
return rec, err
}
+42 -19
View File
@@ -1,13 +1,15 @@
package db
import (
"context"
"time"
"context"
"time"
)
const locationColumns = "id, tenant_id, name, type, capacity, timezone, created_at"
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
SELECT `+locationColumns+`
FROM locations
WHERE tenant_id = $1
ORDER BY name
@@ -20,7 +22,7 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin
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 {
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
@@ -31,20 +33,20 @@ func (r *PGRepository) ListLocationsByTenant(ctx context.Context, tenantID strin
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
SELECT `+locationColumns+`
FROM locations
WHERE id = $1
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Timezone, &rec.CreatedAt)
`, locationID).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &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)
INSERT INTO locations (tenant_id, name, type, capacity, timezone)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+locationColumns+`
`, params.TenantID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
@@ -53,11 +55,13 @@ func (r *PGRepository) UpdateLocation(ctx context.Context, locationID string, pa
err := r.pool.QueryRow(ctx, `
UPDATE locations
SET name = COALESCE($2, name),
timezone = COALESCE($3, timezone),
type = COALESCE($3, type),
capacity = COALESCE($4, capacity),
timezone = COALESCE($5, 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)
RETURNING `+locationColumns+`
`, locationID, params.Name, params.Type, params.Capacity, params.Timezone).Scan(&rec.ID, &rec.TenantID, &rec.Name, &rec.Type, &rec.Capacity, &rec.Timezone, &rec.CreatedAt)
return rec, err
}
@@ -120,7 +124,7 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string
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
SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text
FROM availability_rules
WHERE tenant_id = $1 AND staff_id IS NULL
ORDER BY day_of_week
@@ -142,11 +146,30 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st
}
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)
if params.IsOpen != nil && !*params.IsOpen {
_, err := r.pool.Exec(ctx, `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek)
return err
}
if params.StartsLocal == nil || params.EndsLocal == nil {
return nil
}
// availability_rules has no unique constraint on (tenant, day): replace the
// tenant-level row so repeated updates stay idempotent.
if _, err := r.pool.Exec(ctx, `
DELETE FROM availability_rules
WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL
`, tenantID, dayOfWeek, params.StartsLocal, params.EndsLocal)
`, tenantID, dayOfWeek); err != nil {
return err
}
_, err := r.pool.Exec(ctx, `
INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local)
VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time)
`, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal)
return err
}
+152 -26
View File
@@ -19,7 +19,9 @@ type Repository interface {
GetTenantByBillingCustomerID(ctx context.Context, customerID string) (TenantRecord, error)
EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error
CreateTenantForUser(ctx context.Context, params CreateTenantForUserParams) (TenantMembershipRecord, error)
GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error)
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)
@@ -34,8 +36,9 @@ type Repository interface {
GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error)
GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error)
UpsertSubscriptionSnapshot(ctx context.Context, params BillingSnapshotRecord) error
UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
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
@@ -87,6 +90,11 @@ type Repository interface {
ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error)
UpdateWorkingHours(ctx context.Context, tenantID 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
@@ -190,6 +198,13 @@ type BrandProfileRecord struct {
UmamiSiteID string
}
type UpdateBrandProfileParams struct {
Name *string
SiteURL *string
LogoURL *string
PrimaryColor *string
}
type AvailabilityBlockRecord struct {
DayOfWeek int
StartsLocal string
@@ -246,6 +261,8 @@ type BookingRecord struct {
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
}
type CreateBookingParams struct {
@@ -262,13 +279,15 @@ type CreateBookingParams struct {
EndsAt time.Time
Status string
Reference string
ManageToken string
Notes string
}
type CreatedBooking struct {
ID string
Reference string
Status string
ID string
Reference string
Status string
ManageToken string
}
type WaitlistEntryParams struct {
@@ -341,6 +360,8 @@ type LocationRecord struct {
ID string
TenantID string
Name string
Type string
Capacity int
Timezone string
CreatedAt time.Time
}
@@ -348,11 +369,15 @@ type LocationRecord struct {
type CreateLocationParams struct {
TenantID string
Name string
Type string
Capacity int
Timezone string
}
type UpdateLocationParams struct {
Name *string
Type *string
Capacity *int
Timezone *string
}
@@ -428,6 +453,18 @@ type UpdateWorkingHoursParams struct {
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
@@ -592,11 +629,12 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
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
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
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.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes)
return rec, err
}
@@ -638,6 +676,7 @@ type MemoryRepository struct {
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
}
func NewMemoryRepository() *MemoryRepository {
@@ -659,7 +698,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
membership: TenantMembershipRecord{
@@ -672,7 +711,7 @@ func NewMemoryRepository() *MemoryRepository {
Timezone: "Europe/Prague",
PlanCode: "pro",
SubscriptionStatus: "active",
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: stringPtr("ctm_demo_bookra"),
},
UserID: "demo-owner",
@@ -719,7 +758,7 @@ func NewMemoryRepository() *MemoryRepository {
deliveryLogs: []NotificationDeliveryLogParams{},
billingSnapshot: BillingSnapshotRecord{
TenantID: tenantID,
BillingProvider: "paddle",
BillingProvider: "stripe",
BillingCustomerID: "ctm_demo_bookra",
BillingSubscriptionID: "",
Status: "active",
@@ -841,16 +880,46 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
return r.membership, nil
}
func (r *MemoryRepository) GetTenantMembershipByUserID(_ context.Context, userID string) (TenantMembershipRecord, error) {
if userID == "" || userID == "demo-owner" {
func (r *MemoryRepository) GetTenantMembershipBySubject(_ context.Context, subject string) (TenantMembershipRecord, error) {
if subject == "" || subject == "demo-owner" {
return r.membership, nil
}
if userID == r.membership.UserID {
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
@@ -909,9 +978,10 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
ID: uuid.NewString(),
Reference: params.Reference,
Status: params.Status,
ManageToken: params.ManageToken,
}
r.bookings = append(r.bookings, BookingRecord{
ID: created.ID,
@@ -922,10 +992,13 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
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,
})
return created, nil
}
@@ -1033,38 +1106,38 @@ func (r *MemoryRepository) UpsertSubscriptionSnapshot(_ context.Context, params
return nil
}
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, customerID string) error {
func (r *MemoryRepository) UpdateTenantBillingCustomerID(_ context.Context, tenantID string, provider string, customerID string) error {
if tenantID != r.tenant.ID {
return pgx.ErrNoRows
}
r.tenant.BillingProvider = "paddle"
r.tenant.BillingProvider = provider
r.tenant.BillingCustomerID = &customerID
r.membership.Tenant.BillingProvider = "paddle"
r.membership.Tenant.BillingProvider = provider
r.membership.Tenant.BillingCustomerID = &customerID
r.billingSnapshot.BillingProvider = "paddle"
r.billingSnapshot.BillingProvider = provider
r.billingSnapshot.BillingCustomerID = customerID
return nil
}
func (r *MemoryRepository) UpdateTenantBillingState(_ context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
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 = "paddle"
r.tenant.BillingProvider = provider
r.membership.Tenant.PlanCode = planCode
r.membership.Tenant.SubscriptionStatus = subscriptionStatus
r.membership.Tenant.BillingProvider = "paddle"
r.membership.Tenant.BillingProvider = provider
r.billingSnapshot.PlanCode = planCode
r.billingSnapshot.Status = subscriptionStatus
r.billingSnapshot.BillingProvider = "paddle"
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, "paddle") + ":" + eventID
key := firstNonEmpty(provider, "stripe") + ":" + eventID
if _, exists := r.recordedEvents[key]; exists {
return false, nil
}
@@ -1103,6 +1176,8 @@ func (r *MemoryRepository) CreateLocation(_ context.Context, params CreateLocati
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
Type: params.Type,
Capacity: params.Capacity,
Timezone: params.Timezone,
CreatedAt: time.Now(),
}
@@ -1116,6 +1191,12 @@ func (r *MemoryRepository) UpdateLocation(_ context.Context, locationID string,
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
}
@@ -1352,6 +1433,51 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string
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
// ============================================
+2 -2
View File
@@ -1,8 +1,8 @@
package db
import (
"context"
"time"
"context"
"time"
)
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
+80 -19
View File
@@ -2,6 +2,8 @@ package db
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
@@ -49,9 +51,20 @@ func (r *PGRepository) GetTenantByBillingCustomerID(ctx context.Context, custome
}
func (r *PGRepository) EnsureUserIdentity(ctx context.Context, subject string, email string, displayName string) error {
// Link an existing password-auth account to this Neon subject when the
// email already exists but has no subject attached yet.
if trimmed := strings.TrimSpace(email); trimmed != "" {
if _, err := r.pool.Exec(ctx, `
UPDATE users SET neon_subject = $1
WHERE email = $2 AND neon_subject IS NULL
`, subject, trimmed); err != nil {
return err
}
}
_, 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, ''))
VALUES (gen_random_uuid(), $1, COALESCE(NULLIF($2, ''), $1 || '@users.bookra.invalid'), COALESCE(NULLIF($3, ''), ''))
ON CONFLICT (neon_subject) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF($3, ''), users.display_name)
`, subject, email, displayName)
return err
@@ -123,21 +136,22 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
_, err = tx.Exec(ctx, `
INSERT INTO users (id, neon_subject, email, display_name)
VALUES (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (neon_subject) DO NOTHING
`, params.Subject, params.Subject+"@users.bookra.invalid", "")
SELECT gen_random_uuid(), $1, $2, ''
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE neon_subject = $1 OR id::text = $1
)
`, params.Subject, params.Subject+"@users.bookra.invalid")
if err != nil {
return TenantMembershipRecord{}, err
}
var userID string
var membershipID string
err = tx.QueryRow(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
SELECT gen_random_uuid(), $1, u.neon_subject, 'owner', now()
FROM users u WHERE u.neon_subject = $2
VALUES (gen_random_uuid(), $1, $2, 'owner', now())
ON CONFLICT (tenant_id, user_neon_subject) DO UPDATE SET role = 'owner'
RETURNING id
`, tenantID, params.Subject).Scan(&userID)
`, tenantID, params.Subject).Scan(&membershipID)
if err != nil {
return TenantMembershipRecord{}, err
}
@@ -156,7 +170,7 @@ func (r *PGRepository) CreateTenantForUser(ctx context.Context, params CreateTen
Locale: params.Locale,
Timezone: params.Timezone,
},
UserID: userID,
UserID: membershipID,
Role: "owner",
}, nil
}
@@ -170,7 +184,7 @@ func (r *PGRepository) GetBrandProfile(ctx context.Context, tenantID string) (Br
return record, err
}
func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID string) (TenantMembershipRecord, error) {
func (r *PGRepository) GetTenantMembershipBySubject(ctx context.Context, subject 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,
@@ -178,11 +192,10 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s
tm.user_neon_subject, tm.role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
JOIN users u ON u.neon_subject = tm.user_neon_subject
WHERE u.id = $1
WHERE tm.user_neon_subject = $1
ORDER BY tm.joined_at DESC
LIMIT 1
`, userID).Scan(
`, subject).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,
@@ -191,20 +204,68 @@ func (r *PGRepository) GetTenantMembershipByUserID(ctx context.Context, userID s
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, customerID string) error {
func (r *PGRepository) IsAdminSubject(ctx context.Context, subject string) (bool, error) {
var isAdmin bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM users
WHERE (neon_subject = $1 OR id::text = $1)
AND role IN ('admin', 'superadmin')
)
`, subject).Scan(&isAdmin)
return isAdmin, err
}
func (r *PGRepository) GetTenantOwnerEmail(ctx context.Context, tenantID string) (string, error) {
var email string
err := r.pool.QueryRow(ctx, `
SELECT u.email
FROM tenant_memberships tm
JOIN users u ON u.neon_subject = tm.user_neon_subject OR u.id::text = tm.user_neon_subject
WHERE tm.tenant_id = $1 AND tm.role = 'owner'
ORDER BY tm.joined_at ASC
LIMIT 1
`, tenantID).Scan(&email)
return email, err
}
func (r *PGRepository) UpdateBrandProfile(ctx context.Context, tenantID string, params UpdateBrandProfileParams) (BrandProfileRecord, error) {
var record BrandProfileRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO brand_profiles (tenant_id, name, site_url, logo_url, primary_color)
VALUES ($1, '', NULL, NULL, NULL)
ON CONFLICT (tenant_id) DO NOTHING
`, tenantID).Scan()
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return BrandProfileRecord{}, err
}
err = r.pool.QueryRow(ctx, `
UPDATE brand_profiles
SET name = COALESCE($2, name),
site_url = COALESCE($3, site_url),
logo_url = COALESCE($4, logo_url),
primary_color = COALESCE($5, primary_color)
WHERE tenant_id = $1
RETURNING tenant_id, name, COALESCE(site_url, ''), COALESCE(logo_url, ''), COALESCE(primary_color, ''), COALESCE(umami_site_id, '')
`, tenantID, params.Name, params.SiteURL, params.LogoURL, params.PrimaryColor).Scan(
&record.TenantID, &record.Name, &record.SiteURL, &record.LogoURL, &record.PrimaryColor, &record.UmamiSiteID)
return record, err
}
func (r *PGRepository) UpdateTenantBillingCustomerID(ctx context.Context, tenantID string, provider string, customerID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE tenants
SET billing_provider = 'paddle', billing_customer_id = $2, updated_at = now()
SET billing_provider = $2, billing_customer_id = $3, updated_at = now()
WHERE id = $1
`, tenantID, customerID)
`, tenantID, provider, customerID)
return err
}
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error {
func (r *PGRepository) UpdateTenantBillingState(ctx context.Context, tenantID string, provider 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()
SET billing_provider = $2, plan_code = $3, subscription_status = $4, billing_subscription_id = $5, updated_at = now()
WHERE id = $1
`, tenantID, planCode, subscriptionStatus, subscriptionID)
`, tenantID, provider, planCode, subscriptionStatus, subscriptionID)
return err
}