rdev/internal/db/migrations/013_project_domains.sql
jordan c86516c53a feat: Add multi-domain support with auto-generated slugs for landing page cookbook
Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:55:59 -07:00

100 lines
4.0 KiB
PL/PgSQL

-- Add slug to projects and create project_domains table for flexible multi-domain support.
-- This enables:
-- 1. Auto-generated random subdomains (e.g., k7m2x9p4.threesix.ai)
-- 2. Optional custom subdomains (e.g., my-app.threesix.ai)
-- 3. Multiple alias domains per project (e.g., www.myapp.com, myapp.com)
-- 4. Clean tracking of all DNS records for resource management
-- Add slug column to projects (immutable, auto-generated identifier)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS
slug VARCHAR(16);
-- Backfill existing projects with slugs derived from their IDs
-- Uses first 8 chars of MD5 hash for consistent, reproducible slugs
UPDATE projects
SET slug = LOWER(SUBSTRING(MD5(id), 1, 8))
WHERE slug IS NULL;
-- Make slug NOT NULL and UNIQUE after backfill
ALTER TABLE projects ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);
-- Project domains table for flexible multi-domain support
CREATE TABLE IF NOT EXISTS project_domains (
id SERIAL PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('primary_auto', 'primary_custom', 'alias')),
dns_record_id VARCHAR(64), -- Cloudflare record ID for cleanup
dns_record_type VARCHAR(10), -- A, CNAME, etc.
verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_domain UNIQUE (domain)
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_project_domains_project_id ON project_domains(project_id);
CREATE INDEX IF NOT EXISTS idx_project_domains_type ON project_domains(type);
-- Migrate existing domain data to project_domains table
-- Only insert if the domain column has a value and doesn't already exist in project_domains
INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at)
SELECT
id,
domain,
'primary_auto',
'A',
TRUE,
created_at,
updated_at
FROM projects
WHERE domain IS NOT NULL
AND domain != ''
AND NOT EXISTS (
SELECT 1 FROM project_domains pd WHERE pd.domain = projects.domain
);
-- Migrate custom_domain if present
INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at)
SELECT
id,
custom_domain,
'primary_custom',
'A',
TRUE,
created_at,
updated_at
FROM projects
WHERE custom_domain IS NOT NULL
AND custom_domain != ''
AND NOT EXISTS (
SELECT 1 FROM project_domains pd WHERE pd.domain = projects.custom_domain
);
-- Update trigger for project_domains updated_at
CREATE OR REPLACE FUNCTION update_project_domains_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS project_domains_updated_at ON project_domains;
CREATE TRIGGER project_domains_updated_at
BEFORE UPDATE ON project_domains
FOR EACH ROW
EXECUTE FUNCTION update_project_domains_updated_at();
-- Comments
COMMENT ON TABLE project_domains IS 'Domains associated with projects - supports multiple domains per project';
COMMENT ON COLUMN project_domains.project_id IS 'Reference to parent project';
COMMENT ON COLUMN project_domains.domain IS 'Fully qualified domain name (e.g., k7m2x9p4.threesix.ai or www.myapp.com)';
COMMENT ON COLUMN project_domains.type IS 'Domain type: primary_auto (system-generated), primary_custom (user subdomain), alias (external domain)';
COMMENT ON COLUMN project_domains.dns_record_id IS 'Cloudflare DNS record ID for automated cleanup';
COMMENT ON COLUMN project_domains.dns_record_type IS 'DNS record type: A, CNAME, etc.';
COMMENT ON COLUMN project_domains.verified IS 'Whether domain ownership has been verified (for external domains)';
COMMENT ON COLUMN projects.slug IS 'Immutable 8-char random identifier used for auto-generated subdomain';