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 }