init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
81
internal/db/db.go
Normal file
81
internal/db/db.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const defaultDSN = "postgres://writekit:writekit@localhost:5432/writekit?sslmode=disable"
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func Connect(migrationsDir string) (*DB, error) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = defaultDSN
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{pool: pool}
|
||||
|
||||
if err := db.RunMigrations(migrationsDir); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("migrations: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) RunMigrations(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := db.pool.Exec(context.Background(), string(content)); err != nil {
|
||||
return fmt.Errorf("run %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
db.pool.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
109
internal/db/demos.go
Normal file
109
internal/db/demos.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func getDemoDuration() time.Duration {
|
||||
if mins := os.Getenv("DEMO_DURATION_MINUTES"); mins != "" {
|
||||
if m, err := strconv.Atoi(mins); err == nil && m > 0 {
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
}
|
||||
if os.Getenv("ENV") != "prod" {
|
||||
return 100 * 365 * 24 * time.Hour // infinite in local/dev
|
||||
}
|
||||
return 15 * time.Minute
|
||||
}
|
||||
|
||||
func (db *DB) GetDemoBySubdomain(ctx context.Context, subdomain string) (*Demo, error) {
|
||||
var d Demo
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, subdomain, expires_at FROM demos WHERE subdomain = $1 AND expires_at > NOW()`,
|
||||
subdomain).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &d, err
|
||||
}
|
||||
|
||||
func (db *DB) GetDemoByID(ctx context.Context, id string) (*Demo, error) {
|
||||
var d Demo
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, subdomain, expires_at FROM demos WHERE id = $1 AND expires_at > NOW()`,
|
||||
id).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &d, err
|
||||
}
|
||||
|
||||
func (db *DB) CreateDemo(ctx context.Context) (*Demo, error) {
|
||||
subdomain := "demo-" + randomHex(4)
|
||||
expiresAt := time.Now().Add(getDemoDuration())
|
||||
|
||||
var d Demo
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`INSERT INTO demos (subdomain, expires_at) VALUES ($1, $2) RETURNING id, subdomain, expires_at`,
|
||||
subdomain, expiresAt).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
type ExpiredDemo struct {
|
||||
ID string
|
||||
Subdomain string
|
||||
}
|
||||
|
||||
func (db *DB) CleanupExpiredDemos(ctx context.Context) ([]ExpiredDemo, error) {
|
||||
rows, err := db.pool.Query(ctx,
|
||||
`DELETE FROM demos WHERE expires_at < NOW() RETURNING id, subdomain`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var demos []ExpiredDemo
|
||||
for rows.Next() {
|
||||
var d ExpiredDemo
|
||||
if err := rows.Scan(&d.ID, &d.Subdomain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
demos = append(demos, d)
|
||||
}
|
||||
return demos, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ListActiveDemos(ctx context.Context) ([]Demo, error) {
|
||||
rows, err := db.pool.Query(ctx,
|
||||
`SELECT id, subdomain, expires_at FROM demos WHERE expires_at > NOW()`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var demos []Demo
|
||||
for rows.Next() {
|
||||
var d Demo
|
||||
if err := rows.Scan(&d.ID, &d.Subdomain, &d.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
demos = append(demos, d)
|
||||
}
|
||||
return demos, rows.Err()
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
185
internal/db/migrations/001_initial.sql
Normal file
185
internal/db/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
-- Users (provider-agnostic)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(255),
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- OAuth identities
|
||||
CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
provider_id VARCHAR(255) NOT NULL,
|
||||
provider_email VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(provider, provider_id)
|
||||
);
|
||||
|
||||
-- Sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Tenants (blog instances)
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
subdomain VARCHAR(63) UNIQUE NOT NULL,
|
||||
custom_domain VARCHAR(255),
|
||||
premium BOOLEAN DEFAULT FALSE,
|
||||
members_enabled BOOLEAN DEFAULT FALSE,
|
||||
donations_enabled BOOLEAN DEFAULT FALSE,
|
||||
auth_google_enabled BOOLEAN DEFAULT TRUE,
|
||||
auth_github_enabled BOOLEAN DEFAULT TRUE,
|
||||
auth_discord_enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Demos (temporary blogs)
|
||||
CREATE TABLE IF NOT EXISTS demos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subdomain VARCHAR(63) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Reserved subdomains
|
||||
CREATE TABLE IF NOT EXISTS reserved_subdomains (
|
||||
subdomain VARCHAR(63) PRIMARY KEY,
|
||||
reason VARCHAR(255)
|
||||
);
|
||||
|
||||
-- Membership tiers
|
||||
CREATE TABLE IF NOT EXISTS membership_tiers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
price_cents INTEGER NOT NULL,
|
||||
description TEXT,
|
||||
benefits TEXT[],
|
||||
lemon_variant_id VARCHAR(64),
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Subscriptions
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
tier_id UUID REFERENCES membership_tiers(id) ON DELETE SET NULL,
|
||||
tier_name VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
lemon_subscription_id VARCHAR(64) UNIQUE,
|
||||
lemon_customer_id VARCHAR(64),
|
||||
amount_cents INTEGER NOT NULL,
|
||||
current_period_start TIMESTAMP WITH TIME ZONE,
|
||||
current_period_end TIMESTAMP WITH TIME ZONE,
|
||||
cancelled_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Donations
|
||||
CREATE TABLE IF NOT EXISTS donations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
donor_email VARCHAR(255),
|
||||
donor_name VARCHAR(255),
|
||||
amount_cents INTEGER NOT NULL,
|
||||
lemon_order_id VARCHAR(64) UNIQUE,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Earnings ledger
|
||||
CREATE TABLE IF NOT EXISTS earnings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
source_type VARCHAR(20) NOT NULL,
|
||||
source_id UUID NOT NULL,
|
||||
description TEXT,
|
||||
gross_cents INTEGER NOT NULL,
|
||||
platform_fee_cents INTEGER NOT NULL,
|
||||
processor_fee_cents INTEGER NOT NULL,
|
||||
net_cents INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Balances
|
||||
CREATE TABLE IF NOT EXISTS balances (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
available_cents INTEGER DEFAULT 0,
|
||||
lifetime_earnings_cents INTEGER DEFAULT 0,
|
||||
lifetime_paid_cents INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Payouts
|
||||
CREATE TABLE IF NOT EXISTS payouts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
wise_transfer_id VARCHAR(64),
|
||||
wise_quote_id VARCHAR(64),
|
||||
status VARCHAR(20) NOT NULL,
|
||||
failure_reason TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Payout settings
|
||||
CREATE TABLE IF NOT EXISTS payout_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
wise_recipient_id VARCHAR(64),
|
||||
account_holder_name VARCHAR(255),
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
payout_email VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_identities_lookup ON user_identities(provider, provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
|
||||
CREATE INDEX IF NOT EXISTS idx_demos_expires ON demos(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_demos_subdomain ON demos(subdomain);
|
||||
CREATE INDEX IF NOT EXISTS idx_tiers_tenant ON membership_tiers(tenant_id) WHERE active = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON subscriptions(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_lemon ON subscriptions(lemon_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_donations_tenant ON donations(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_earnings_tenant ON earnings(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_tenant ON payouts(tenant_id);
|
||||
|
||||
-- Reserved subdomains
|
||||
INSERT INTO reserved_subdomains (subdomain, reason) VALUES
|
||||
('www', 'system'),
|
||||
('api', 'system'),
|
||||
('app', 'system'),
|
||||
('admin', 'system'),
|
||||
('staging', 'system'),
|
||||
('demo', 'system'),
|
||||
('test', 'system'),
|
||||
('mail', 'system'),
|
||||
('smtp', 'system'),
|
||||
('ftp', 'system'),
|
||||
('ssh', 'system'),
|
||||
('traefik', 'system'),
|
||||
('ops', 'system'),
|
||||
('source', 'system'),
|
||||
('ci', 'system')
|
||||
ON CONFLICT (subdomain) DO NOTHING;
|
||||
34
internal/db/models.go
Normal file
34
internal/db/models.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
Subdomain string
|
||||
CustomDomain string
|
||||
Premium bool
|
||||
MembersEnabled bool
|
||||
DonationsEnabled bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Demo struct {
|
||||
ID string
|
||||
Subdomain string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
45
internal/db/sessions.go
Normal file
45
internal/db/sessions.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (db *DB) CreateSession(ctx context.Context, userID string) (*Session, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Session{
|
||||
Token: hex.EncodeToString(token),
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
_, err := db.pool.Exec(ctx,
|
||||
`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
s.Token, s.UserID, s.ExpiresAt)
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (db *DB) ValidateSession(ctx context.Context, token string) (*Session, error) {
|
||||
var s Session
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT token, user_id, expires_at FROM sessions
|
||||
WHERE token = $1 AND expires_at > NOW()`,
|
||||
token).Scan(&s.Token, &s.UserID, &s.ExpiresAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
147
internal/db/tenants.go
Normal file
147
internal/db/tenants.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (db *DB) GetTenantBySubdomain(ctx context.Context, subdomain string) (*Tenant, error) {
|
||||
var t Tenant
|
||||
var ownerID, customDomain *string
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
|
||||
FROM tenants WHERE subdomain = $1`,
|
||||
subdomain).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
|
||||
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if ownerID != nil {
|
||||
t.OwnerID = *ownerID
|
||||
}
|
||||
if customDomain != nil {
|
||||
t.CustomDomain = *customDomain
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (db *DB) GetTenantByOwner(ctx context.Context, ownerID string) (*Tenant, error) {
|
||||
var t Tenant
|
||||
var owner, customDomain *string
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
|
||||
FROM tenants WHERE owner_id = $1`,
|
||||
ownerID).Scan(&t.ID, &owner, &t.Subdomain, &customDomain, &t.Premium,
|
||||
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if owner != nil {
|
||||
t.OwnerID = *owner
|
||||
}
|
||||
if customDomain != nil {
|
||||
t.CustomDomain = *customDomain
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (db *DB) CreateTenant(ctx context.Context, ownerID, subdomain string) (*Tenant, error) {
|
||||
var t Tenant
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`INSERT INTO tenants (owner_id, subdomain)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at`,
|
||||
ownerID, subdomain).Scan(&t.ID, &t.OwnerID, &t.Subdomain, &t.CustomDomain, &t.Premium,
|
||||
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (db *DB) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
|
||||
var t Tenant
|
||||
var ownerID, customDomain *string
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
|
||||
FROM tenants WHERE id = $1`,
|
||||
id).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
|
||||
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if ownerID != nil {
|
||||
t.OwnerID = *ownerID
|
||||
}
|
||||
if customDomain != nil {
|
||||
t.CustomDomain = *customDomain
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (db *DB) IsUserTenantOwner(ctx context.Context, userID, tenantID string) (bool, error) {
|
||||
var exists bool
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND owner_id = $2)`,
|
||||
tenantID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (db *DB) IsSubdomainAvailable(ctx context.Context, subdomain string) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM reserved_subdomains WHERE subdomain = $1)`,
|
||||
subdomain).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err = db.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM demos WHERE subdomain = $1 AND expires_at > NOW())`,
|
||||
subdomain).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err = db.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`,
|
||||
subdomain).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return !exists, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListTenants(ctx context.Context) ([]Tenant, error) {
|
||||
rows, err := db.pool.Query(ctx,
|
||||
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
|
||||
FROM tenants`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tenants []Tenant
|
||||
for rows.Next() {
|
||||
var t Tenant
|
||||
var ownerID, customDomain *string
|
||||
if err := rows.Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
|
||||
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ownerID != nil {
|
||||
t.OwnerID = *ownerID
|
||||
}
|
||||
if customDomain != nil {
|
||||
t.CustomDomain = *customDomain
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
return tenants, rows.Err()
|
||||
}
|
||||
62
internal/db/users.go
Normal file
62
internal/db/users.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (db *DB) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
var u User
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, email, name, avatar_url, created_at FROM users WHERE id = $1`,
|
||||
id).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var u User
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT id, email, name, avatar_url, created_at FROM users WHERE email = $1`,
|
||||
email).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByIdentity(ctx context.Context, provider, providerID string) (*User, error) {
|
||||
var u User
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`SELECT u.id, u.email, u.name, u.avatar_url, u.created_at
|
||||
FROM users u
|
||||
JOIN user_identities i ON i.user_id = u.id
|
||||
WHERE i.provider = $1 AND i.provider_id = $2`,
|
||||
provider, providerID).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(ctx context.Context, email, name, avatarURL string) (*User, error) {
|
||||
var u User
|
||||
err := db.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, name, avatar_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, avatar_url, created_at`,
|
||||
email, name, avatarURL).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (db *DB) AddUserIdentity(ctx context.Context, userID, provider, providerID, providerEmail string) error {
|
||||
_, err := db.pool.Exec(ctx,
|
||||
`INSERT INTO user_identities (user_id, provider, provider_id, provider_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (provider, provider_id) DO NOTHING`,
|
||||
userID, provider, providerID, providerEmail)
|
||||
return err
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue