Skip to main content

max / makenotwork

6.9 KB · 233 lines History Blame Raw
1 //! Live slug availability validation endpoints (HTMX partials).
2
3 use axum::{extract::State, response::Html};
4 use serde::Deserialize;
5
6 use sqlx::PgPool;
7
8 use crate::{
9 auth::AuthUser,
10 db::{self, ProjectId, Slug},
11 templates::{SaveStatusTemplate, SlugStatusTemplate},
12 };
13
14 /// Generate up to 3 available slug suggestions based on a taken slug.
15 /// Strips any trailing `-N` suffix to find the root, then tries `{root}-2`
16 /// through `{root}-10`, returning the first 3 that are not already taken.
17 async fn suggest_slugs(db: &sqlx::PgPool, user_id: db::UserId, base: &str) -> Vec<String> {
18 // Strip any trailing -N suffix to get the root
19 let root = base
20 .rfind('-')
21 .and_then(|pos| {
22 if base[pos + 1..].chars().all(|c| c.is_ascii_digit()) {
23 Some(&base[..pos])
24 } else {
25 None
26 }
27 })
28 .unwrap_or(base);
29
30 // Build all valid candidate slugs in order, then resolve availability with a
31 // single `slug = ANY(...)` query instead of one point-query per candidate.
32 let candidates: Vec<String> = (2..=10)
33 .map(|n| format!("{root}-{n}"))
34 .filter(|c| Slug::new(c).is_ok())
35 .collect();
36 if candidates.is_empty() {
37 return Vec::new();
38 }
39
40 // On a DB error, suggest nothing rather than risk offering a taken slug.
41 let Ok(taken) = db::projects::filter_taken_slugs(db, user_id, &candidates).await else {
42 return Vec::new();
43 };
44 let taken: std::collections::HashSet<String> = taken.into_iter().collect();
45
46 candidates
47 .into_iter()
48 .filter(|c| !taken.contains(c))
49 .take(3)
50 .collect()
51 }
52
53 #[derive(Debug, Deserialize)]
54 pub(super) struct SlugForm {
55 pub slug: String,
56 }
57
58 #[derive(Debug, Deserialize)]
59 pub(super) struct BlogSlugForm {
60 pub slug: String,
61 pub project_id: String,
62 }
63
64 /// Check project slug availability for the current user.
65 #[tracing::instrument(skip_all, name = "validate::project_slug")]
66 pub(super) async fn validate_project_slug(
67 State(db): State<PgPool>,
68 auth: AuthUser,
69 axum::Form(form): axum::Form<SlugForm>,
70 ) -> crate::error::Result<Html<String>> {
71 if form.slug.is_empty() {
72 return Ok(Html(String::new()));
73 }
74 if form.slug.len() < 2 {
75 return Ok(Html(
76 SaveStatusTemplate {
77 success: false,
78 message: "Must be at least 2 characters".to_string(),
79 }
80 .render_string()?,
81 ));
82 }
83
84 let Ok(slug) = Slug::new(&form.slug) else {
85 return Ok(Html(
86 SaveStatusTemplate {
87 success: false,
88 message: "Letters, numbers, and hyphens only".to_string(),
89 }
90 .render_string()?,
91 ));
92 };
93
94 let is_taken = match db::projects::get_project_by_user_and_slug(&db, auth.0.id, &slug).await {
95 Ok(p) => p.is_some(),
96 Err(e) => return slug_check_failed(&e),
97 };
98
99 if is_taken {
100 let suggestions = suggest_slugs(&db, auth.0.id, &form.slug).await;
101 Ok(Html(
102 SlugStatusTemplate {
103 available: false,
104 suggestions,
105 }
106 .render_string()?,
107 ))
108 } else {
109 Ok(Html(
110 SlugStatusTemplate {
111 available: true,
112 suggestions: Vec::new(),
113 }
114 .render_string()?,
115 ))
116 }
117 }
118
119 /// Check collection slug availability for the current user.
120 #[tracing::instrument(skip_all, name = "validate::collection_slug")]
121 pub(super) async fn validate_collection_slug(
122 State(db): State<PgPool>,
123 auth: AuthUser,
124 axum::Form(form): axum::Form<SlugForm>,
125 ) -> crate::error::Result<Html<String>> {
126 if form.slug.is_empty() {
127 return Ok(Html(String::new()));
128 }
129 if form.slug.len() < 2 {
130 return Ok(Html(
131 SaveStatusTemplate {
132 success: false,
133 message: "Must be at least 2 characters".to_string(),
134 }
135 .render_string()?,
136 ));
137 }
138
139 let Ok(slug) = Slug::new(&form.slug) else {
140 return Ok(Html(
141 SaveStatusTemplate {
142 success: false,
143 message: "Letters, numbers, and hyphens only".to_string(),
144 }
145 .render_string()?,
146 ));
147 };
148
149 let is_taken =
150 match db::collections::get_collection_by_user_and_slug(&db, auth.0.id, &slug).await {
151 Ok(c) => c.is_some(),
152 Err(e) => return slug_check_failed(&e),
153 };
154
155 Ok(Html(
156 SlugStatusTemplate {
157 available: !is_taken,
158 suggestions: Vec::new(),
159 }
160 .render_string()?,
161 ))
162 }
163
164 /// Check blog post slug availability within a project.
165 #[tracing::instrument(skip_all, name = "validate::blog_slug")]
166 pub(super) async fn validate_blog_slug(
167 State(db): State<PgPool>,
168 auth: AuthUser,
169 axum::Form(form): axum::Form<BlogSlugForm>,
170 ) -> crate::error::Result<Html<String>> {
171 if form.slug.is_empty() {
172 return Ok(Html(String::new()));
173 }
174 if form.slug.len() < 2 {
175 return Ok(Html(
176 SaveStatusTemplate {
177 success: false,
178 message: "Must be at least 2 characters".to_string(),
179 }
180 .render_string()?,
181 ));
182 }
183
184 let Ok(slug) = Slug::new(&form.slug) else {
185 return Ok(Html(
186 SaveStatusTemplate {
187 success: false,
188 message: "Letters, numbers, and hyphens only".to_string(),
189 }
190 .render_string()?,
191 ));
192 };
193
194 let Ok(project_id) = form.project_id.parse::<ProjectId>() else {
195 return Ok(Html(String::new()));
196 };
197
198 // Verify the user owns this project
199 let project = match db::projects::get_project_by_id(&db, project_id).await {
200 Ok(Some(p)) if p.user_id == auth.0.id => p,
201 _ => return Ok(Html(String::new())),
202 };
203
204 let is_taken = match db::blog_posts::blog_post_slug_exists(&db, project.id, &slug).await {
205 Ok(taken) => taken,
206 Err(e) => return slug_check_failed(&e),
207 };
208
209 Ok(Html(
210 SlugStatusTemplate {
211 available: !is_taken,
212 suggestions: Vec::new(),
213 }
214 .render_string()?,
215 ))
216 }
217
218 /// Fail-closed response for a slug-availability check whose DB query errored.
219 /// Reporting "available" on error (the old `.unwrap_or(false)`) misleads the
220 /// creator into thinking a possibly-taken slug is free; the real save then
221 /// fails on the uniqueness constraint. Surface a retry instead (Run #21 UX LOW,
222 /// carry-forward from #18).
223 fn slug_check_failed(e: &crate::error::AppError) -> crate::error::Result<Html<String>> {
224 tracing::warn!(error = ?e, "slug availability check failed; reporting retry");
225 Ok(Html(
226 SaveStatusTemplate {
227 success: false,
228 message: "Couldn't check availability right now, try again".to_string(),
229 }
230 .render_string()?,
231 ))
232 }
233