//! Live slug availability validation endpoints (HTMX partials). use axum::{extract::State, response::Html}; use serde::Deserialize; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, ProjectId, Slug}, templates::{SaveStatusTemplate, SlugStatusTemplate}, }; /// Generate up to 3 available slug suggestions based on a taken slug. /// Strips any trailing `-N` suffix to find the root, then tries `{root}-2` /// through `{root}-10`, returning the first 3 that are not already taken. async fn suggest_slugs(db: &sqlx::PgPool, user_id: db::UserId, base: &str) -> Vec { // Strip any trailing -N suffix to get the root let root = base .rfind('-') .and_then(|pos| { if base[pos + 1..].chars().all(|c| c.is_ascii_digit()) { Some(&base[..pos]) } else { None } }) .unwrap_or(base); // Build all valid candidate slugs in order, then resolve availability with a // single `slug = ANY(...)` query instead of one point-query per candidate. let candidates: Vec = (2..=10) .map(|n| format!("{root}-{n}")) .filter(|c| Slug::new(c).is_ok()) .collect(); if candidates.is_empty() { return Vec::new(); } // On a DB error, suggest nothing rather than risk offering a taken slug. let Ok(taken) = db::projects::filter_taken_slugs(db, user_id, &candidates).await else { return Vec::new(); }; let taken: std::collections::HashSet = taken.into_iter().collect(); candidates .into_iter() .filter(|c| !taken.contains(c)) .take(3) .collect() } #[derive(Debug, Deserialize)] pub(super) struct SlugForm { pub slug: String, } #[derive(Debug, Deserialize)] pub(super) struct BlogSlugForm { pub slug: String, pub project_id: String, } /// Check project slug availability for the current user. #[tracing::instrument(skip_all, name = "validate::project_slug")] pub(super) async fn validate_project_slug( State(db): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> crate::error::Result> { if form.slug.is_empty() { return Ok(Html(String::new())); } if form.slug.len() < 2 { return Ok(Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string()?, )); } let Ok(slug) = Slug::new(&form.slug) else { return Ok(Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string()?, )); }; let is_taken = match db::projects::get_project_by_user_and_slug(&db, auth.0.id, &slug).await { Ok(p) => p.is_some(), Err(e) => return slug_check_failed(&e), }; if is_taken { let suggestions = suggest_slugs(&db, auth.0.id, &form.slug).await; Ok(Html( SlugStatusTemplate { available: false, suggestions, } .render_string()?, )) } else { Ok(Html( SlugStatusTemplate { available: true, suggestions: Vec::new(), } .render_string()?, )) } } /// Check collection slug availability for the current user. #[tracing::instrument(skip_all, name = "validate::collection_slug")] pub(super) async fn validate_collection_slug( State(db): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> crate::error::Result> { if form.slug.is_empty() { return Ok(Html(String::new())); } if form.slug.len() < 2 { return Ok(Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string()?, )); } let Ok(slug) = Slug::new(&form.slug) else { return Ok(Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string()?, )); }; let is_taken = match db::collections::get_collection_by_user_and_slug(&db, auth.0.id, &slug).await { Ok(c) => c.is_some(), Err(e) => return slug_check_failed(&e), }; Ok(Html( SlugStatusTemplate { available: !is_taken, suggestions: Vec::new(), } .render_string()?, )) } /// Check blog post slug availability within a project. #[tracing::instrument(skip_all, name = "validate::blog_slug")] pub(super) async fn validate_blog_slug( State(db): State, auth: AuthUser, axum::Form(form): axum::Form, ) -> crate::error::Result> { if form.slug.is_empty() { return Ok(Html(String::new())); } if form.slug.len() < 2 { return Ok(Html( SaveStatusTemplate { success: false, message: "Must be at least 2 characters".to_string(), } .render_string()?, )); } let Ok(slug) = Slug::new(&form.slug) else { return Ok(Html( SaveStatusTemplate { success: false, message: "Letters, numbers, and hyphens only".to_string(), } .render_string()?, )); }; let Ok(project_id) = form.project_id.parse::() else { return Ok(Html(String::new())); }; // Verify the user owns this project let project = match db::projects::get_project_by_id(&db, project_id).await { Ok(Some(p)) if p.user_id == auth.0.id => p, _ => return Ok(Html(String::new())), }; let is_taken = match db::blog_posts::blog_post_slug_exists(&db, project.id, &slug).await { Ok(taken) => taken, Err(e) => return slug_check_failed(&e), }; Ok(Html( SlugStatusTemplate { available: !is_taken, suggestions: Vec::new(), } .render_string()?, )) } /// Fail-closed response for a slug-availability check whose DB query errored. /// Reporting "available" on error (an `.unwrap_or(false)`) misleads the creator /// into thinking a possibly-taken slug is free; the real save then fails on the /// uniqueness constraint. Surface a retry instead. fn slug_check_failed(e: &crate::error::AppError) -> crate::error::Result> { tracing::warn!(error = ?e, "slug availability check failed; reporting retry"); Ok(Html( SaveStatusTemplate { success: false, message: "Couldn't check availability right now, try again".to_string(), } .render_string()?, )) }