mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
- 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>
272 lines
10 KiB
Go
272 lines
10 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
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,
|
|
)
|
|
return record, err
|
|
}
|
|
|
|
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,
|
|
)
|
|
return record, err
|
|
}
|
|
|
|
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,
|
|
)
|
|
return record, err
|
|
}
|
|
|
|
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'), 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
|
|
}
|
|
|
|
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 func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var tenantID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO tenants (id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider)
|
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'starter', 'inactive', '')
|
|
RETURNING id
|
|
`, params.Slug, params.Name, params.Preset, params.Locale, params.Timezone).Scan(&tenantID)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
|
|
_, 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,''))
|
|
`, tenantID, params.BrandName, params.SiteURL, params.LogoURL, params.PrimaryColor)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
|
|
locationID := ""
|
|
if params.LocationName != "" {
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO locations (id, tenant_id, name, timezone)
|
|
VALUES (gen_random_uuid(), $1, $2, $3)
|
|
RETURNING id
|
|
`, tenantID, params.LocationName, params.Timezone).Scan(&locationID)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
}
|
|
|
|
if params.ServiceName != "" && params.DurationMinutes > 0 {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO services (id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
|
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 0)
|
|
`, tenantID, params.ServiceName, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
}
|
|
|
|
for _, block := range params.AvailabilityBlocks {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO availability_rules (id, tenant_id, day_of_week, starts_local, ends_local)
|
|
VALUES (gen_random_uuid(), $1, $2, $3, $4)
|
|
`, tenantID, block.DayOfWeek, block.StartsLocal, block.EndsLocal)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
}
|
|
|
|
for _, invite := range params.TeamInvites {
|
|
_, _ = tx.Exec(ctx, `
|
|
INSERT INTO team_invites (tenant_id, email, role, expires_at)
|
|
VALUES ($1, $2, $3, now() + interval '7 days')
|
|
`, tenantID, invite.Email, invite.Role)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO users (id, neon_subject, email, display_name)
|
|
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 membershipID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO tenant_memberships (id, tenant_id, user_neon_subject, role, joined_at)
|
|
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(&membershipID)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
|
|
err = tx.Commit(ctx)
|
|
if err != nil {
|
|
return TenantMembershipRecord{}, err
|
|
}
|
|
|
|
return TenantMembershipRecord{
|
|
Tenant: TenantRecord{
|
|
ID: tenantID,
|
|
Slug: params.Slug,
|
|
Name: params.Name,
|
|
Preset: params.Preset,
|
|
Locale: params.Locale,
|
|
Timezone: params.Timezone,
|
|
},
|
|
UserID: membershipID,
|
|
Role: "owner",
|
|
}, 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) 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,
|
|
t.billing_provider, t.billing_customer_id, t.billing_subscription_id,
|
|
tm.user_neon_subject, tm.role
|
|
FROM tenant_memberships tm
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE tm.user_neon_subject = $1
|
|
ORDER BY tm.joined_at DESC
|
|
LIMIT 1
|
|
`, 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,
|
|
&record.UserID, &record.Role,
|
|
)
|
|
return record, err
|
|
}
|
|
|
|
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 = $2, billing_customer_id = $3, updated_at = now()
|
|
WHERE id = $1
|
|
`, tenantID, provider, customerID)
|
|
return err
|
|
}
|
|
|
|
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 = $2, plan_code = $3, subscription_status = $4, billing_subscription_id = $5, updated_at = now()
|
|
WHERE id = $1
|
|
`, tenantID, provider, planCode, subscriptionStatus, subscriptionID)
|
|
return err
|
|
}
|