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