//! Custom domain CRUD queries. use sqlx::PgPool; use super::models::DbCustomDomain; use super::{CustomDomainId, UserId}; use crate::error::{AppError, Result}; /// Create a custom domain entry with a verification token. /// Enforces a 1-domain-per-user limit using a transaction to prevent TOCTOU races. #[tracing::instrument(skip_all)] pub async fn create_custom_domain( pool: &PgPool, user_id: UserId, domain: &str, verification_token: &str, ) -> Result { let mut tx = pool.begin().await?; // Lock existing rows to serialize concurrent domain creation attempts let existing: Vec<(CustomDomainId,)> = sqlx::query_as( "SELECT id FROM custom_domains WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_all(&mut *tx) .await?; if !existing.is_empty() { return Err(AppError::BadRequest( "You already have a custom domain configured. Remove it first to add a new one.".to_string(), )); } let row = sqlx::query_as::<_, DbCustomDomain>( r#" INSERT INTO custom_domains (user_id, domain, verification_token) VALUES ($1, $2, $3) RETURNING * "#, ) .bind(user_id) .bind(domain) .bind(verification_token) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(row) } /// Get the custom domain for a user (at most one). #[tracing::instrument(skip_all)] pub async fn get_custom_domain_by_user( pool: &PgPool, user_id: UserId, ) -> Result> { let row = sqlx::query_as::<_, DbCustomDomain>( "SELECT * FROM custom_domains WHERE user_id = $1", ) .bind(user_id) .fetch_optional(pool) .await?; Ok(row) } /// Look up a verified domain by hostname (for routing). #[tracing::instrument(skip_all)] pub async fn get_verified_domain( pool: &PgPool, domain: &str, ) -> Result> { let row = sqlx::query_as::<_, DbCustomDomain>( "SELECT * FROM custom_domains WHERE domain = $1 AND verified = true", ) .bind(domain) .fetch_optional(pool) .await?; Ok(row) } /// Mark a domain as verified. #[tracing::instrument(skip_all)] pub async fn mark_domain_verified(pool: &PgPool, domain_id: CustomDomainId) -> Result<()> { sqlx::query("UPDATE custom_domains SET verified = true, verified_at = NOW() WHERE id = $1") .bind(domain_id) .execute(pool) .await?; Ok(()) } /// Delete a custom domain (only if owned by the given user). #[tracing::instrument(skip_all)] pub async fn delete_custom_domain( pool: &PgPool, domain_id: CustomDomainId, user_id: UserId, ) -> Result<()> { let result = sqlx::query( "DELETE FROM custom_domains WHERE id = $1 AND user_id = $2", ) .bind(domain_id) .bind(user_id) .execute(pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound); } Ok(()) } /// Get all verified domains (for cache warm-up on startup). #[tracing::instrument(skip_all)] pub async fn get_all_verified_domains(pool: &PgPool) -> Result> { let rows = sqlx::query_as::<_, DbCustomDomain>( "SELECT * FROM custom_domains WHERE verified = true", ) .fetch_all(pool) .await?; Ok(rows) } #[cfg(test)] mod tests { use super::*; #[test] fn custom_domain_id_new_is_unique() { let a = CustomDomainId::new(); let b = CustomDomainId::new(); assert_ne!(a, b); } #[test] fn custom_domain_id_nil() { let nil = CustomDomainId::nil(); assert_eq!(*nil.as_uuid(), uuid::Uuid::nil()); } #[test] fn custom_domain_id_display() { let id = CustomDomainId::nil(); assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000"); } #[test] fn custom_domain_id_serde_roundtrip() { let id = CustomDomainId::new(); let json = serde_json::to_string(&id).unwrap(); let parsed: CustomDomainId = serde_json::from_str(&json).unwrap(); assert_eq!(id, parsed); } #[test] fn bad_request_error_contains_message() { let err = AppError::BadRequest( "You already have a custom domain configured. Remove it first to add a new one." .to_string(), ); let msg = err.user_message(); assert!(msg.contains("already have a custom domain")); } #[test] fn not_found_error_status() { let err = AppError::NotFound; assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND); } #[test] fn user_id_and_custom_domain_id_are_distinct_types() { // Compile-time type safety: these are different types wrapping UUIDs. let uid = UserId::new(); let did = CustomDomainId::new(); assert_ne!(uid.as_uuid(), did.as_uuid()); } #[test] fn db_custom_domain_struct_is_clone() { // DbCustomDomain derives Clone — verify it compiles. fn assert_clone() {} assert_clone::(); } #[test] fn db_custom_domain_struct_is_debug() { fn assert_debug() {} assert_debug::(); } }