This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

81
internal/db/db.go Normal file
View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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
}