rdev/internal/adapter/postgres/project_domain.go
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

316 lines
9.5 KiB
Go

// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// ProjectDomainRepository implements port.ProjectDomainRepository using PostgreSQL.
type ProjectDomainRepository struct {
db *sql.DB
}
// NewProjectDomainRepository creates a new PostgreSQL project domain repository.
func NewProjectDomainRepository(db *sql.DB) *ProjectDomainRepository {
return &ProjectDomainRepository{db: db}
}
// Ensure ProjectDomainRepository implements port.ProjectDomainRepository at compile time.
var _ port.ProjectDomainRepository = (*ProjectDomainRepository)(nil)
// Create adds a new domain association for a project.
func (r *ProjectDomainRepository) Create(ctx context.Context, pd *domain.ProjectDomain) error {
now := time.Now()
err := r.db.QueryRowContext(ctx, `
INSERT INTO project_domains (project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING id
`, pd.ProjectID, strings.ToLower(pd.Domain), pd.Type, nullString(pd.DNSRecordID),
nullString(pd.DNSRecordType), pd.Verified, now).Scan(&pd.ID)
if err != nil {
if strings.Contains(err.Error(), "unique_domain") || strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("domain %q already exists: %w", pd.Domain, domain.ErrDuplicateDomain)
}
return fmt.Errorf("create project domain: %w", err)
}
pd.CreatedAt = now
pd.UpdatedAt = now
return nil
}
// GetByID retrieves a domain by its ID.
func (r *ProjectDomainRepository) GetByID(ctx context.Context, id int64) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE id = $1
`, id).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get project domain by id: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// GetByDomain retrieves a domain by its FQDN.
func (r *ProjectDomainRepository) GetByDomain(ctx context.Context, fqdn string) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE LOWER(domain) = LOWER($1)
`, fqdn).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get project domain by fqdn: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// ListByProject returns all domains for a project.
func (r *ProjectDomainRepository) ListByProject(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE project_id = $1
ORDER BY
CASE type
WHEN 'primary_auto' THEN 1
WHEN 'primary_custom' THEN 2
ELSE 3
END,
created_at
`, projectID)
if err != nil {
return nil, fmt.Errorf("list project domains: %w", err)
}
defer func() { _ = rows.Close() }()
var domains []*domain.ProjectDomain
for rows.Next() {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
if err := rows.Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project domain: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
domains = append(domains, pd)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate project domains: %w", err)
}
return domains, nil
}
// GetPrimary returns the primary domain for a project.
// Prefers primary_custom over primary_auto if both exist.
func (r *ProjectDomainRepository) GetPrimary(ctx context.Context, projectID string) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE project_id = $1 AND type IN ('primary_auto', 'primary_custom')
ORDER BY CASE type WHEN 'primary_custom' THEN 1 ELSE 2 END
LIMIT 1
`, projectID).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get primary domain: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// Update modifies an existing domain record.
func (r *ProjectDomainRepository) Update(ctx context.Context, pd *domain.ProjectDomain) error {
result, err := r.db.ExecContext(ctx, `
UPDATE project_domains
SET domain = $1, type = $2, dns_record_id = $3, dns_record_type = $4, verified = $5, updated_at = NOW()
WHERE id = $6
`, strings.ToLower(pd.Domain), pd.Type, nullString(pd.DNSRecordID),
nullString(pd.DNSRecordType), pd.Verified, pd.ID)
if err != nil {
if strings.Contains(err.Error(), "unique_domain") || strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("domain %q already exists: %w", pd.Domain, domain.ErrDuplicateDomain)
}
return fmt.Errorf("update project domain: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// Delete removes a domain by ID.
func (r *ProjectDomainRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE id = $1
`, id)
if err != nil {
return fmt.Errorf("delete project domain: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// DeleteByDomain removes a domain by FQDN.
func (r *ProjectDomainRepository) DeleteByDomain(ctx context.Context, fqdn string) error {
result, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE LOWER(domain) = LOWER($1)
`, fqdn)
if err != nil {
return fmt.Errorf("delete project domain by fqdn: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// DeleteByProject removes all domains for a project.
func (r *ProjectDomainRepository) DeleteByProject(ctx context.Context, projectID string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE project_id = $1
`, projectID)
if err != nil {
return fmt.Errorf("delete project domains: %w", err)
}
return nil
}
// Exists checks if a domain already exists.
func (r *ProjectDomainRepository) Exists(ctx context.Context, fqdn string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM project_domains WHERE LOWER(domain) = LOWER($1))
`, fqdn).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check domain exists: %w", err)
}
return exists, nil
}
// CountByProject returns the number of domains for a project.
func (r *ProjectDomainRepository) CountByProject(ctx context.Context, projectID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM project_domains WHERE project_id = $1
`, projectID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count project domains: %w", err)
}
return count, nil
}
// SlugRepository implements port.SlugGenerator using PostgreSQL.
type SlugRepository struct {
db *sql.DB
maxRetries int
}
// NewSlugRepository creates a new PostgreSQL slug generator.
func NewSlugRepository(db *sql.DB) *SlugRepository {
return &SlugRepository{db: db, maxRetries: 10}
}
// Ensure SlugRepository implements port.SlugGenerator at compile time.
var _ port.SlugGenerator = (*SlugRepository)(nil)
// Generate creates a new unique slug.
func (r *SlugRepository) Generate(ctx context.Context) (string, error) {
for range r.maxRetries {
slug, err := domain.GenerateSlug()
if err != nil {
return "", fmt.Errorf("generate slug: %w", err)
}
unique, err := r.IsUnique(ctx, slug)
if err != nil {
return "", err
}
if unique {
return slug, nil
}
}
return "", fmt.Errorf("failed to generate unique slug after %d attempts", r.maxRetries)
}
// IsUnique checks if a slug is unique (not in use).
func (r *SlugRepository) IsUnique(ctx context.Context, slug string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM projects WHERE slug = $1)
`, slug).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check slug unique: %w", err)
}
return !exists, nil
}