Skip to main content

max / makenotwork

5.9 KB · 216 lines History Blame Raw
1 //! Live slug availability validation endpoints (HTMX partials).
2
3 use axum::{
4 extract::State,
5 response::{Html, IntoResponse},
6 };
7 use serde::Deserialize;
8
9 use crate::{
10 auth::AuthUser,
11 db::{self, ProjectId, Slug},
12 templates::{SaveStatusTemplate, SlugStatusTemplate},
13 AppState,
14 };
15
16 /// Generate up to 3 available slug suggestions based on a taken slug.
17 /// Strips any trailing `-N` suffix to find the root, then tries `{root}-2`
18 /// through `{root}-10`, returning the first 3 that are not already taken.
19 async fn suggest_slugs(
20 db: &sqlx::PgPool,
21 user_id: db::UserId,
22 base: &str,
23 ) -> Vec<String> {
24 // Strip any trailing -N suffix to get the root
25 let root = base
26 .rfind('-')
27 .and_then(|pos| {
28 if base[pos + 1..].chars().all(|c| c.is_ascii_digit()) {
29 Some(&base[..pos])
30 } else {
31 None
32 }
33 })
34 .unwrap_or(base);
35
36 let mut suggestions = Vec::new();
37 for n in 2..=10 {
38 let candidate = format!("{}-{}", root, n);
39 if let Ok(slug) = Slug::new(&candidate) {
40 let taken = db::projects::get_project_by_user_and_slug(db, user_id, &slug)
41 .await
42 .map(|p| p.is_some())
43 .unwrap_or(true);
44 if !taken {
45 suggestions.push(candidate);
46 if suggestions.len() >= 3 {
47 break;
48 }
49 }
50 }
51 }
52 suggestions
53 }
54
55 #[derive(Debug, Deserialize)]
56 pub struct SlugForm {
57 pub slug: String,
58 }
59
60 #[derive(Debug, Deserialize)]
61 pub struct BlogSlugForm {
62 pub slug: String,
63 pub project_id: String,
64 }
65
66 /// Check project slug availability for the current user.
67 #[tracing::instrument(skip_all, name = "validate::project_slug")]
68 pub async fn validate_project_slug(
69 State(state): State<AppState>,
70 auth: AuthUser,
71 axum::Form(form): axum::Form<SlugForm>,
72 ) -> impl IntoResponse {
73 if form.slug.is_empty() {
74 return Html(String::new());
75 }
76 if form.slug.len() < 2 {
77 return Html(
78 SaveStatusTemplate {
79 success: false,
80 message: "Must be at least 2 characters".to_string(),
81 }
82 .render_string(),
83 );
84 }
85
86 let slug = match Slug::new(&form.slug) {
87 Ok(s) => s,
88 Err(_) => {
89 return Html(
90 SaveStatusTemplate {
91 success: false,
92 message: "Letters, numbers, and hyphens only".to_string(),
93 }
94 .render_string(),
95 );
96 }
97 };
98
99 let is_taken = db::projects::get_project_by_user_and_slug(&state.db, auth.0.id, &slug)
100 .await
101 .map(|p| p.is_some())
102 .unwrap_or(false);
103
104 if is_taken {
105 let suggestions = suggest_slugs(&state.db, auth.0.id, &form.slug).await;
106 Html(
107 SlugStatusTemplate {
108 available: false,
109 suggestions,
110 }
111 .render_string(),
112 )
113 } else {
114 Html(
115 SlugStatusTemplate {
116 available: true,
117 suggestions: Vec::new(),
118 }
119 .render_string(),
120 )
121 }
122 }
123
124 /// Check collection slug availability for the current user.
125 #[tracing::instrument(skip_all, name = "validate::collection_slug")]
126 pub async fn validate_collection_slug(
127 State(state): State<AppState>,
128 auth: AuthUser,
129 axum::Form(form): axum::Form<SlugForm>,
130 ) -> impl IntoResponse {
131 if form.slug.is_empty() {
132 return Html(String::new());
133 }
134 if form.slug.len() < 2 {
135 return Html(
136 SaveStatusTemplate {
137 success: false,
138 message: "Must be at least 2 characters".to_string(),
139 }
140 .render_string(),
141 );
142 }
143
144 let slug = match Slug::new(&form.slug) {
145 Ok(s) => s,
146 Err(_) => {
147 return Html(
148 SaveStatusTemplate {
149 success: false,
150 message: "Letters, numbers, and hyphens only".to_string(),
151 }
152 .render_string(),
153 );
154 }
155 };
156
157 let is_taken =
158 db::collections::get_collection_by_user_and_slug(&state.db, auth.0.id, &slug)
159 .await
160 .map(|c| c.is_some())
161 .unwrap_or(false);
162
163 Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string())
164 }
165
166 /// Check blog post slug availability within a project.
167 #[tracing::instrument(skip_all, name = "validate::blog_slug")]
168 pub async fn validate_blog_slug(
169 State(state): State<AppState>,
170 auth: AuthUser,
171 axum::Form(form): axum::Form<BlogSlugForm>,
172 ) -> impl IntoResponse {
173 if form.slug.is_empty() {
174 return Html(String::new());
175 }
176 if form.slug.len() < 2 {
177 return Html(
178 SaveStatusTemplate {
179 success: false,
180 message: "Must be at least 2 characters".to_string(),
181 }
182 .render_string(),
183 );
184 }
185
186 let slug = match Slug::new(&form.slug) {
187 Ok(s) => s,
188 Err(_) => {
189 return Html(
190 SaveStatusTemplate {
191 success: false,
192 message: "Letters, numbers, and hyphens only".to_string(),
193 }
194 .render_string(),
195 );
196 }
197 };
198
199 let project_id = match form.project_id.parse::<ProjectId>() {
200 Ok(id) => id,
201 Err(_) => return Html(String::new()),
202 };
203
204 // Verify the user owns this project
205 let project = match db::projects::get_project_by_id(&state.db, project_id).await {
206 Ok(Some(p)) if p.user_id == auth.0.id => p,
207 _ => return Html(String::new()),
208 };
209
210 let is_taken = db::blog_posts::blog_post_slug_exists(&state.db, project.id, &slug)
211 .await
212 .unwrap_or(false);
213
214 Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string())
215 }
216