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>
316 lines
9.5 KiB
Go
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
|
|
}
|