mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 16:04:45 +00:00
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:
1 parent
2039669e2c
commit
f2f7439050
93 files changed
+6987
-69060
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user