Skip to main content

max / makenotwork

5.2 KB · 189 lines History Blame Raw
1 //! Custom domain CRUD queries.
2
3 use sqlx::PgPool;
4
5 use super::models::DbCustomDomain;
6 use super::{CustomDomainId, UserId};
7 use crate::error::{AppError, Result};
8
9 /// Create a custom domain entry with a verification token.
10 /// Enforces a 1-domain-per-user limit using a transaction to prevent TOCTOU races.
11 #[tracing::instrument(skip_all)]
12 pub async fn create_custom_domain(
13 pool: &PgPool,
14 user_id: UserId,
15 domain: &str,
16 verification_token: &str,
17 ) -> Result<DbCustomDomain> {
18 let mut tx = pool.begin().await?;
19
20 // Lock existing rows to serialize concurrent domain creation attempts
21 let existing: Vec<(CustomDomainId,)> =
22 sqlx::query_as("SELECT id FROM custom_domains WHERE user_id = $1 FOR UPDATE")
23 .bind(user_id)
24 .fetch_all(&mut *tx)
25 .await?;
26
27 if !existing.is_empty() {
28 return Err(AppError::BadRequest(
29 "You already have a custom domain configured. Remove it first to add a new one."
30 .to_string(),
31 ));
32 }
33
34 let row = sqlx::query_as::<_, DbCustomDomain>(
35 r"
36 INSERT INTO custom_domains (user_id, domain, verification_token)
37 VALUES ($1, $2, $3)
38 RETURNING *
39 ",
40 )
41 .bind(user_id)
42 .bind(domain)
43 .bind(verification_token)
44 .fetch_one(&mut *tx)
45 .await?;
46
47 tx.commit().await?;
48 Ok(row)
49 }
50
51 /// Get the custom domain for a user (at most one).
52 #[tracing::instrument(skip_all)]
53 pub async fn get_custom_domain_by_user(
54 pool: &PgPool,
55 user_id: UserId,
56 ) -> Result<Option<DbCustomDomain>> {
57 let row =
58 sqlx::query_as::<_, DbCustomDomain>("SELECT * FROM custom_domains WHERE user_id = $1")
59 .bind(user_id)
60 .fetch_optional(pool)
61 .await?;
62
63 Ok(row)
64 }
65
66 /// Look up a verified domain by hostname (for routing).
67 #[tracing::instrument(skip_all)]
68 pub async fn get_verified_domain(pool: &PgPool, domain: &str) -> Result<Option<DbCustomDomain>> {
69 let row = sqlx::query_as::<_, DbCustomDomain>(
70 "SELECT * FROM custom_domains WHERE domain = $1 AND verified = true",
71 )
72 .bind(domain)
73 .fetch_optional(pool)
74 .await?;
75
76 Ok(row)
77 }
78
79 /// Mark a domain as verified.
80 #[tracing::instrument(skip_all)]
81 pub async fn mark_domain_verified(pool: &PgPool, domain_id: CustomDomainId) -> Result<()> {
82 sqlx::query("UPDATE custom_domains SET verified = true, verified_at = NOW() WHERE id = $1")
83 .bind(domain_id)
84 .execute(pool)
85 .await?;
86
87 Ok(())
88 }
89
90 /// Delete a custom domain (only if owned by the given user).
91 #[tracing::instrument(skip_all)]
92 pub async fn delete_custom_domain(
93 pool: &PgPool,
94 domain_id: CustomDomainId,
95 user_id: UserId,
96 ) -> Result<()> {
97 let result = sqlx::query("DELETE FROM custom_domains WHERE id = $1 AND user_id = $2")
98 .bind(domain_id)
99 .bind(user_id)
100 .execute(pool)
101 .await?;
102
103 if result.rows_affected() == 0 {
104 return Err(AppError::NotFound);
105 }
106
107 Ok(())
108 }
109
110 /// Get all verified domains (for cache warm-up on startup).
111 #[tracing::instrument(skip_all)]
112 pub async fn get_all_verified_domains(pool: &PgPool) -> Result<Vec<DbCustomDomain>> {
113 let rows =
114 sqlx::query_as::<_, DbCustomDomain>("SELECT * FROM custom_domains WHERE verified = true")
115 .fetch_all(pool)
116 .await?;
117
118 Ok(rows)
119 }
120
121 #[cfg(test)]
122 mod tests {
123 use super::*;
124
125 #[test]
126 fn custom_domain_id_new_is_unique() {
127 let a = CustomDomainId::new();
128 let b = CustomDomainId::new();
129 assert_ne!(a, b);
130 }
131
132 #[test]
133 fn custom_domain_id_nil() {
134 let nil = CustomDomainId::nil();
135 assert_eq!(*nil.as_uuid(), uuid::Uuid::nil());
136 }
137
138 #[test]
139 fn custom_domain_id_display() {
140 let id = CustomDomainId::nil();
141 assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
142 }
143
144 #[test]
145 fn custom_domain_id_serde_roundtrip() {
146 let id = CustomDomainId::new();
147 let json = serde_json::to_string(&id).unwrap();
148 let parsed: CustomDomainId = serde_json::from_str(&json).unwrap();
149 assert_eq!(id, parsed);
150 }
151
152 #[test]
153 fn bad_request_error_contains_message() {
154 let err = AppError::BadRequest(
155 "You already have a custom domain configured. Remove it first to add a new one."
156 .to_string(),
157 );
158 let msg = err.user_message();
159 assert!(msg.contains("already have a custom domain"));
160 }
161
162 #[test]
163 fn not_found_error_status() {
164 let err = AppError::NotFound;
165 assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
166 }
167
168 #[test]
169 fn user_id_and_custom_domain_id_are_distinct_types() {
170 // Compile-time type safety: these are different types wrapping UUIDs.
171 let uid = UserId::new();
172 let did = CustomDomainId::new();
173 assert_ne!(uid.as_uuid(), did.as_uuid());
174 }
175
176 #[test]
177 fn db_custom_domain_struct_is_clone() {
178 // DbCustomDomain derives Clone, verify it compiles.
179 fn assert_clone<T: Clone>() {}
180 assert_clone::<DbCustomDomain>();
181 }
182
183 #[test]
184 fn db_custom_domain_struct_is_debug() {
185 fn assert_debug<T: std::fmt::Debug>() {}
186 assert_debug::<DbCustomDomain>();
187 }
188 }
189