Skip to main content

max / makenotwork

R27-UX-M3: AppError::Validation now carries per-field errors Closes the last open item from Ultra Fuzz Run 27. The variant changes from `AppError::Validation(String)` to `AppError::Validation(ValidationError)`, where ValidationError carries both a `summary` (the user-visible banner) and an optional `Vec<(field, message)>` so form templates can highlight specific inputs instead of rendering only a global banner. Two constructors keep call-site ergonomics: - `AppError::validation(msg)` — the common path; takes anything that satisfies `Into<ValidationError>` (String, &str, &String). All 187 pre-existing call sites migrate mechanically via: sed 's/AppError::Validation(/AppError::validation(/g' with `.into()` redundancy stripped where the compiler flagged ambiguity. - `AppError::validation_fields(summary, [(field, msg), ...])` — for handlers that know which input was at fault. Templates branch on field-awareness via the new accessor `AppError::validation_fields_ref() -> Option<&[(String, String)]>` — plain summaries return None so the renderer can skip per-field UI. Adopted in `step_account_create` (join wizard), the highest-value multi-field validation site: email/username/password each carry their own field tag now. The full-page rendering path uses validation_fields; the HTMX path keeps the legacy single-line response template for now (separate cleanup). Tests - 7 new unit tests in `error::tests::` covering From impls, with_field chaining, both constructors, and the field accessor's None-on-empty contract. - All pre-existing 187 sites typecheck against the new variant.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 04:28 UTC
Commit: 612deeb56dc07910d302f5401441d5582db135dc
Parent: b7a993b
35 files changed, +387 insertions, -200 deletions
@@ -146,8 +146,8 @@ impl Email {
146 146 if normalized.len() > EMAIL_MAX_LEN
147 147 || !email_address::EmailAddress::is_valid(&normalized)
148 148 {
149 - return Err(AppError::Validation(
150 - "Please enter a valid email address".into(),
149 + return Err(AppError::validation(
150 + "Please enter a valid email address",
151 151 ));
152 152 }
153 153 Ok(Self(normalized))
@@ -388,12 +388,12 @@ impl PriceCents {
388 388 /// Validate and construct. Rejects negative values and values exceeding the cap.
389 389 pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
390 390 if cents < 0 {
391 - return Err(crate::error::AppError::Validation(
391 + return Err(crate::error::AppError::validation(
392 392 "Price cannot be negative".to_string(),
393 393 ));
394 394 }
395 395 if cents > crate::constants::MAX_PRICE_CENTS {
396 - return Err(crate::error::AppError::Validation(
396 + return Err(crate::error::AppError::validation(
397 397 "Price cannot exceed $10,000".to_string(),
398 398 ));
399 399 }
@@ -12,6 +12,61 @@ use axum::{
12 12 #[derive(Clone)]
13 13 pub struct ApiErrorMessage(pub String);
14 14
15 + /// Payload carried by `AppError::Validation`.
16 + ///
17 + /// Always has a `summary` (the user-visible banner above the form). Optionally
18 + /// carries one or more `(field, message)` pairs so form templates can highlight
19 + /// specific inputs instead of rendering only a global banner.
20 + ///
21 + /// Construct via `From<String>` / `From<&str>` when you only have a message,
22 + /// or use `ValidationError::new(summary).with_field(...)` for field-scoped
23 + /// errors. The matching `AppError::validation(msg)` and
24 + /// `AppError::validation_fields(summary, fields)` constructors are the usual
25 + /// entry points from handlers.
26 + #[derive(Debug, Clone, PartialEq, Eq)]
27 + pub struct ValidationError {
28 + pub summary: String,
29 + pub fields: Vec<(String, String)>,
30 + }
31 +
32 + impl ValidationError {
33 + pub fn new(summary: impl Into<String>) -> Self {
34 + Self {
35 + summary: summary.into(),
36 + fields: Vec::new(),
37 + }
38 + }
39 +
40 + pub fn with_field(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
41 + self.fields.push((field.into(), message.into()));
42 + self
43 + }
44 + }
45 +
46 + impl std::fmt::Display for ValidationError {
47 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 + f.write_str(&self.summary)
49 + }
50 + }
51 +
52 + impl From<String> for ValidationError {
53 + fn from(summary: String) -> Self {
54 + ValidationError::new(summary)
55 + }
56 + }
57 +
58 + impl From<&str> for ValidationError {
59 + fn from(summary: &str) -> Self {
60 + ValidationError::new(summary.to_string())
61 + }
62 + }
63 +
64 + impl From<&String> for ValidationError {
65 + fn from(summary: &String) -> Self {
66 + ValidationError::new(summary.clone())
67 + }
68 + }
69 +
15 70 /// Application error type that can be converted into an HTTP response
16 71 #[derive(Debug, thiserror::Error)]
17 72 pub enum AppError {
@@ -28,7 +83,7 @@ pub enum AppError {
28 83 BadRequest(String),
29 84
30 85 #[error("Validation error: {0}")]
31 - Validation(String),
86 + Validation(ValidationError),
32 87
33 88 #[error("Database error: {0}")]
34 89 Database(#[from] sqlx::Error),
@@ -59,6 +114,42 @@ pub enum AppError {
59 114 }
60 115
61 116 impl AppError {
117 + /// Construct a plain validation error with just a summary message.
118 + ///
119 + /// Equivalent to the old `AppError::validation("msg".to_string())` shape —
120 + /// all 187 mechanical migration sites use this. Reach for
121 + /// [`AppError::validation_fields`] instead when you know which input was
122 + /// at fault and want the form template to highlight it.
123 + pub fn validation(summary: impl Into<ValidationError>) -> Self {
124 + AppError::Validation(summary.into())
125 + }
126 +
127 + /// Construct a field-scoped validation error.
128 + ///
129 + /// `summary` is the banner shown above the form; `fields` is a list of
130 + /// `(field_name, message)` pairs the template uses to attach errors to
131 + /// specific inputs. Field names must match the corresponding `<input
132 + /// name=...>` so the template can pair them up.
133 + pub fn validation_fields(
134 + summary: impl Into<String>,
135 + fields: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
136 + ) -> Self {
137 + let mut err = ValidationError::new(summary);
138 + for (k, v) in fields {
139 + err = err.with_field(k, v);
140 + }
141 + AppError::Validation(err)
142 + }
143 +
144 + /// If this error is a validation error with per-field detail, return the
145 + /// field list. Templates use this to render per-input error messages.
146 + pub fn validation_fields_ref(&self) -> Option<&[(String, String)]> {
147 + match self {
148 + AppError::Validation(v) if !v.fields.is_empty() => Some(&v.fields),
149 + _ => None,
150 + }
151 + }
152 +
62 153 /// Static tag for Prometheus metric labels (e.g. `kind="database"`)
63 154 pub fn tag(&self) -> &'static str {
64 155 match self {
@@ -106,7 +197,7 @@ impl AppError {
106 197 AppError::Unauthorized => "You need to log in to access this page.".to_string(),
107 198 AppError::Forbidden => "You don't have permission to access this page.".to_string(),
108 199 AppError::BadRequest(msg) => msg.clone(),
109 - AppError::Validation(msg) => msg.clone(),
200 + AppError::Validation(v) => v.summary.clone(),
110 201 AppError::InvalidFileType(msg) => msg.clone(),
111 202 AppError::FileTooLarge(msg) => msg.clone(),
112 203 AppError::MalwareDetected(_) => {
@@ -238,7 +329,7 @@ mod tests {
238 329 #[test]
239 330 fn status_code_validation() {
240 331 assert_eq!(
241 - AppError::Validation("test".into()).status_code(),
332 + AppError::validation("test").status_code(),
242 333 StatusCode::UNPROCESSABLE_ENTITY
243 334 );
244 335 }
@@ -258,7 +349,7 @@ mod tests {
258 349
259 350 #[test]
260 351 fn user_message_validation_passes_through() {
261 - let msg = AppError::Validation("Name too long".into()).user_message();
352 + let msg = AppError::validation("Name too long").user_message();
262 353 assert_eq!(msg, "Name too long");
263 354 }
264 355
@@ -319,7 +410,7 @@ mod tests {
319 410
320 411 #[test]
321 412 fn into_response_validation() {
322 - let (status, body, _) = response_status_and_body(AppError::Validation("too long".into()));
413 + let (status, body, _) = response_status_and_body(AppError::validation("too long"));
323 414 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
324 415 assert_eq!(body, "too long");
325 416 }
@@ -433,7 +524,7 @@ mod tests {
433 524 assert_eq!(AppError::Unauthorized.tag(), "unauthorized");
434 525 assert_eq!(AppError::Forbidden.tag(), "forbidden");
435 526 assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request");
436 - assert_eq!(AppError::Validation("x".into()).tag(), "validation");
527 + assert_eq!(AppError::validation("x").tag(), "validation");
437 528 assert_eq!(AppError::Storage("x".into()).tag(), "storage");
438 529 assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type");
439 530 assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large");
@@ -456,4 +547,70 @@ mod tests {
456 547 assert!(!msg.contains("Win.Trojan"));
457 548 assert!(msg.contains("security scanner"));
458 549 }
550 +
551 + // ── ValidationError + field-scoped Validation ───────────────────────
552 +
553 + #[test]
554 + fn validation_error_from_string_has_no_fields() {
555 + let v: ValidationError = "boom".to_string().into();
556 + assert_eq!(v.summary, "boom");
557 + assert!(v.fields.is_empty());
558 + }
559 +
560 + #[test]
561 + fn validation_error_from_str_has_no_fields() {
562 + let v: ValidationError = "boom".into();
563 + assert_eq!(v.summary, "boom");
564 + assert!(v.fields.is_empty());
565 + }
566 +
567 + #[test]
568 + fn validation_error_with_field_chains() {
569 + let v = ValidationError::new("Please fix the fields")
570 + .with_field("username", "Already taken")
571 + .with_field("email", "Invalid");
572 + assert_eq!(v.summary, "Please fix the fields");
573 + assert_eq!(v.fields.len(), 2);
574 + assert_eq!(v.fields[0], ("username".to_string(), "Already taken".to_string()));
575 + assert_eq!(v.fields[1], ("email".to_string(), "Invalid".to_string()));
576 + }
577 +
578 + #[test]
579 + fn validation_constructor_accepts_plain_string() {
580 + // The most common call shape across the 187 migrated sites.
581 + let e = AppError::validation("nope");
582 + assert_eq!(e.tag(), "validation");
583 + assert_eq!(e.user_message(), "nope");
584 + assert!(e.validation_fields_ref().is_none());
585 + }
586 +
587 + #[test]
588 + fn validation_constructor_accepts_owned_string() {
589 + let e = AppError::validation("nope".to_string());
590 + assert_eq!(e.user_message(), "nope");
591 + }
592 +
593 + #[test]
594 + fn validation_fields_constructor_exposes_field_list() {
595 + let e = AppError::validation_fields(
596 + "Please fix the highlighted fields",
597 + [("username", "Already taken"), ("email", "Invalid")],
598 + );
599 + assert_eq!(e.tag(), "validation");
600 + assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
601 + assert_eq!(e.user_message(), "Please fix the highlighted fields");
602 + let fields = e.validation_fields_ref().expect("fields present");
603 + assert_eq!(fields.len(), 2);
604 + assert_eq!(fields[0].0, "username");
605 + assert_eq!(fields[1].0, "email");
606 + }
607 +
608 + #[test]
609 + fn validation_without_fields_returns_none_from_ref() {
610 + // The accessor is "fields-only" — a plain validation error returns
611 + // None so templates can branch on field-awareness without filtering
612 + // empty vecs.
613 + let e = AppError::validation("just a summary");
614 + assert!(e.validation_fields_ref().is_none());
615 + }
459 616 }
@@ -128,8 +128,8 @@ pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload>
128 128 }
129 129
130 130 if payload.subscribers.is_empty() && payload.transactions.is_empty() {
131 - return Err(AppError::Validation(
132 - "No valid rows found. Check your column mapping and CSV format.".into(),
131 + return Err(AppError::validation(
132 + "No valid rows found. Check your column mapping and CSV format.",
133 133 ));
134 134 }
135 135
@@ -78,7 +78,7 @@ async fn admin_mt_provision(
78 78 AdminUser(_admin): AdminUser,
79 79 ) -> Result<Response> {
80 80 let Some(ref mt) = state.mt_client else {
81 - return Err(AppError::Validation("MT integration not configured".to_string()));
81 + return Err(AppError::validation("MT integration not configured".to_string()));
82 82 };
83 83
84 84 let projects = db::projects::get_projects_without_mt_community(&state.db).await?;
@@ -155,7 +155,7 @@ async fn admin_shutdown_notice(
155 155 ) -> Result<Response> {
156 156 let shutdown_date = form.shutdown_date.trim();
157 157 if shutdown_date.is_empty() {
158 - return Err(AppError::Validation("Shutdown date is required".to_string()));
158 + return Err(AppError::validation("Shutdown date is required".to_string()));
159 159 }
160 160
161 161 let all_users = db::users::get_all_user_emails(&state.db).await?;
@@ -55,7 +55,7 @@ pub(super) async fn admin_decide_appeal(
55 55 ) -> Result<impl IntoResponse> {
56 56 let response_text = form.response.trim();
57 57 if response_text.is_empty() {
58 - return Err(AppError::Validation("Response is required".to_string()));
58 + return Err(AppError::validation("Response is required".to_string()));
59 59 }
60 60
61 61 // Get user for email notification
@@ -167,7 +167,7 @@ pub(super) async fn admin_resolve_report(
167 167 let status = match form.decision.as_str() {
168 168 "resolve" => ReportStatus::Resolved,
169 169 "dismiss" => ReportStatus::Dismissed,
170 - _ => return Err(AppError::Validation("Invalid decision".to_string())),
170 + _ => return Err(AppError::validation("Invalid decision".to_string())),
171 171 };
172 172
173 173 db::reports::resolve_report(
@@ -206,7 +206,7 @@ pub(super) async fn admin_remove_item(
206 206 ) -> Result<impl IntoResponse> {
207 207 let reason = form.reason.trim();
208 208 if reason.is_empty() {
209 - return Err(AppError::Validation("Removal reason is required".to_string()));
209 + return Err(AppError::validation("Removal reason is required".to_string()));
210 210 }
211 211
212 212 let item = db::items::admin_remove_item(&state.db, item_id, reason).await?;
@@ -107,7 +107,7 @@ pub(super) async fn admin_warn_user(
107 107 ) -> Result<impl IntoResponse> {
108 108 let reason = form.reason.trim();
109 109 if reason.is_empty() {
110 - return Err(AppError::Validation("Reason is required".to_string()));
110 + return Err(AppError::validation("Reason is required".to_string()));
111 111 }
112 112
113 113 let db_user = db::users::get_user_by_id(&state.db, id)
@@ -140,7 +140,7 @@ pub(super) async fn admin_suspend_user(
140 140 ) -> Result<impl IntoResponse> {
141 141 let reason = form.reason.trim();
142 142 if reason.is_empty() {
143 - return Err(AppError::Validation("Reason is required".to_string()));
143 + return Err(AppError::validation("Reason is required".to_string()));
144 144 }
145 145
146 146 // Get user for email notification
@@ -242,13 +242,13 @@ pub(super) async fn admin_terminate_user(
242 242 .ok_or(AppError::NotFound)?;
243 243
244 244 if !db_user.is_suspended() {
245 - return Err(AppError::Validation(
245 + return Err(AppError::validation(
246 246 "Account must be suspended before termination".to_string(),
247 247 ));
248 248 }
249 249
250 250 if db_user.terminated_at.is_some() {
251 - return Err(AppError::Validation(
251 + return Err(AppError::validation(
252 252 "Account is already terminated".to_string(),
253 253 ));
254 254 }
@@ -129,7 +129,7 @@ pub(super) async fn admin_lottery(
129 129 ) -> Result<Response> {
130 130
131 131 if form.count < 1 {
132 - return Err(AppError::Validation("Count must be at least 1".to_string()));
132 + return Err(AppError::validation("Count must be at least 1".to_string()));
133 133 }
134 134
135 135 // Use a transaction for atomicity
@@ -209,7 +209,7 @@ pub(super) async fn update_blog_post(
209 209 if req.slug != existing.slug
210 210 && db::blog_posts::blog_post_slug_exists(&state.db, existing.project_id, &req.slug).await?
211 211 {
212 - return Err(AppError::Validation("A blog post with this slug already exists".to_string()));
212 + return Err(AppError::validation("A blog post with this slug already exists".to_string()));
213 213 }
214 214
215 215 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
@@ -65,7 +65,7 @@ pub(super) async fn create_category(
65 65 ) -> Result<impl IntoResponse> {
66 66 let name = req.name.trim();
67 67 if name.is_empty() || name.len() > 100 {
68 - return Err(crate::error::AppError::Validation(
68 + return Err(crate::error::AppError::validation(
69 69 "Category name must be 1-100 characters".to_string(),
70 70 ));
71 71 }
@@ -100,7 +100,7 @@ pub(super) async fn create_collection(
100 100 // Enforce per-user limit
101 101 let count = db::collections::count_collections_by_user(&state.db, user.id).await?;
102 102 if count >= constants::MAX_COLLECTIONS_PER_USER {
103 - return Err(AppError::Validation(format!(
103 + return Err(AppError::validation(format!(
104 104 "You can create up to {} collections",
105 105 constants::MAX_COLLECTIONS_PER_USER
106 106 )));
@@ -120,7 +120,7 @@ pub(super) async fn create_collection(
120 120 Err(crate::error::AppError::Database(sqlx::Error::Database(ref db_err)))
121 121 if db_err.code().as_deref() == Some("23505") =>
122 122 {
123 - return Err(AppError::Validation(
123 + return Err(AppError::validation(
124 124 "You already have a collection with this slug".to_string(),
125 125 ));
126 126 }
@@ -206,7 +206,7 @@ pub(super) async fn add_item(
206 206 .await?
207 207 .ok_or(AppError::NotFound)?;
208 208 if !item.is_public {
209 - return Err(AppError::Validation(
209 + return Err(AppError::validation(
210 210 "Only public items can be added to collections".to_string(),
211 211 ));
212 212 }
@@ -214,7 +214,7 @@ pub(super) async fn add_item(
214 214 // Enforce per-collection limit
215 215 let count = db::collections::count_collection_items(&state.db, collection_id).await?;
216 216 if count >= constants::MAX_ITEMS_PER_COLLECTION {
217 - return Err(AppError::Validation(format!(
217 + return Err(AppError::validation(format!(
218 218 "A collection can hold up to {} items",
219 219 constants::MAX_ITEMS_PER_COLLECTION
220 220 )));
@@ -235,7 +235,7 @@ fn normalize_domain(input: &str) -> Result<String> {
235 235 }
236 236
237 237 if domain.is_empty() {
238 - return Err(AppError::Validation("Domain cannot be empty.".to_string()));
238 + return Err(AppError::validation("Domain cannot be empty.".to_string()));
239 239 }
240 240
241 241 Ok(domain)
@@ -244,19 +244,19 @@ fn normalize_domain(input: &str) -> Result<String> {
244 244 /// Basic domain validation: must have at least one dot, no spaces, reasonable length.
245 245 fn validate_domain(domain: &str) -> Result<()> {
246 246 if domain.len() > 253 {
247 - return Err(AppError::Validation(
247 + return Err(AppError::validation(
248 248 "Domain name is too long.".to_string(),
249 249 ));
250 250 }
251 251
252 252 if !domain.contains('.') {
253 - return Err(AppError::Validation(
253 + return Err(AppError::validation(
254 254 "Domain must include a TLD (e.g. example.com).".to_string(),
255 255 ));
256 256 }
257 257
258 258 if domain.contains(' ') || domain.contains('\t') {
259 - return Err(AppError::Validation(
259 + return Err(AppError::validation(
260 260 "Domain cannot contain spaces.".to_string(),
261 261 ));
262 262 }
@@ -267,7 +267,7 @@ fn validate_domain(domain: &str) -> Result<()> {
267 267 || domain == "makenotwork.com"
268 268 || domain.ends_with(".makenotwork.com")
269 269 {
270 - return Err(AppError::Validation(
270 + return Err(AppError::validation(
271 271 "Cannot use a makenot.work domain.".to_string(),
272 272 ));
273 273 }
@@ -275,7 +275,7 @@ fn validate_domain(domain: &str) -> Result<()> {
275 275 // Check labels
276 276 for label in domain.split('.') {
277 277 if label.is_empty() || label.len() > 63 {
278 - return Err(AppError::Validation(
278 + return Err(AppError::validation(
279 279 "Each domain label must be 1-63 characters.".to_string(),
280 280 ));
281 281 }
@@ -283,12 +283,12 @@ fn validate_domain(domain: &str) -> Result<()> {
283 283 .chars()
284 284 .all(|c| c.is_ascii_alphanumeric() || c == '-')
285 285 {
286 - return Err(AppError::Validation(
286 + return Err(AppError::validation(
287 287 "Domain labels can only contain letters, numbers, and hyphens.".to_string(),
288 288 ));
289 289 }
290 290 if label.starts_with('-') || label.ends_with('-') {
291 - return Err(AppError::Validation(
291 + return Err(AppError::validation(
292 292 "Domain labels cannot start or end with a hyphen.".to_string(),
293 293 ));
294 294 }
@@ -47,7 +47,7 @@ pub(super) async fn start_import(
47 47
48 48 // Only generic_csv is currently supported
49 49 if req.source != ImportSource::GenericCsv {
50 - return Err(AppError::Validation(format!(
50 + return Err(AppError::validation(format!(
51 51 "Import source '{}' is not yet supported. Only 'generic_csv' is available.",
52 52 req.source,
53 53 )));
@@ -56,10 +56,10 @@ pub(super) async fn start_import(
56 56 // Decode base64 CSV data
57 57 let csv_bytes = base64::engine::general_purpose::STANDARD
58 58 .decode(&req.csv_data)
59 - .map_err(|_| AppError::Validation("Invalid base64-encoded CSV data".into()))?;
59 + .map_err(|_| AppError::validation("Invalid base64-encoded CSV data"))?;
60 60
61 61 if csv_bytes.len() > MAX_CSV_SIZE {
62 - return Err(AppError::Validation(format!(
62 + return Err(AppError::validation(format!(
63 63 "CSV data exceeds maximum size of {} MB",
64 64 MAX_CSV_SIZE / (1024 * 1024),
65 65 )));
@@ -87,7 +87,7 @@ pub(super) async fn add_item_tag(
87 87
88 88 let _tag = db::tags::get_tag_by_id(&state.db, tag_id)
89 89 .await?
90 - .ok_or_else(|| AppError::Validation("Tag not found".to_string()))?;
90 + .ok_or_else(|| AppError::validation("Tag not found".to_string()))?;
91 91
92 92 db::tags::add_tag_to_item(&state.db, req.item_id, tag_id, false).await?;
93 93
@@ -159,10 +159,10 @@ pub(super) async fn send_broadcast(
159 159 Json(req): Json<BroadcastRequest>,
160 160 ) -> Result<impl IntoResponse> {
161 161 if req.subject.is_empty() || req.subject.len() > 200 {
162 - return Err(AppError::Validation("Subject must be 1-200 characters".to_string()));
162 + return Err(AppError::validation("Subject must be 1-200 characters".to_string()));
163 163 }
164 164 if req.body.is_empty() || req.body.len() > 5000 {
165 - return Err(AppError::Validation("Body must be 1-5000 characters".to_string()));
165 + return Err(AppError::validation("Body must be 1-5000 characters".to_string()));
166 166 }
167 167
168 168 let db_user = db::users::get_user_by_id(&state.db, req.user_id)
@@ -175,7 +175,7 @@ pub(super) async fn send_broadcast(
175 175
176 176 let set = db::users::try_set_broadcast_at(&state.db, req.user_id).await?;
177 177 if !set {
178 - return Err(AppError::Validation("You can only send one broadcast per 24 hours".to_string()));
178 + return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string()));
179 179 }
180 180
181 181 let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?;
@@ -304,7 +304,7 @@ pub(super) async fn create_collection(
304 304 Json(req): Json<CreateCollectionRequest>,
305 305 ) -> Result<impl IntoResponse> {
306 306 let slug = Slug::new(&req.slug)
307 - .map_err(|e| AppError::Validation(e.to_string()))?;
307 + .map_err(|e| AppError::validation(e.to_string()))?;
308 308
309 309 let collection = db::collections::create_collection(
310 310 &state.db,
@@ -375,10 +375,10 @@ pub(super) async fn add_domain(
375 375 ) -> Result<impl IntoResponse> {
376 376 let domain = req.domain.to_lowercase().trim().to_string();
377 377 if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
378 - return Err(AppError::Validation("Invalid domain".to_string()));
378 + return Err(AppError::validation("Invalid domain".to_string()));
379 379 }
380 380 if domain.contains("makenot.work") || domain.contains("makenotwork") {
381 - return Err(AppError::Validation("Cannot use MNW domains".to_string()));
381 + return Err(AppError::validation("Cannot use MNW domains".to_string()));
382 382 }
383 383
384 384 let token = generate_verification_token();
@@ -156,10 +156,10 @@ pub(super) async fn create_blog_post(
156 156 // Apply scheduled publish time if provided
157 157 let post = if let Some(ref publish_at_str) = req.publish_at {
158 158 let dt = chrono::DateTime::parse_from_rfc3339(publish_at_str)
159 - .map_err(|_| AppError::Validation("Invalid publish_at datetime (use ISO 8601 / RFC 3339)".to_string()))?;
159 + .map_err(|_| AppError::validation("Invalid publish_at datetime (use ISO 8601 / RFC 3339)".to_string()))?;
160 160 let dt_utc = dt.with_timezone(&chrono::Utc);
161 161 if dt_utc <= chrono::Utc::now() {
162 - return Err(AppError::Validation("publish_at must be in the future".to_string()));
162 + return Err(AppError::validation("publish_at must be in the future".to_string()));
163 163 }
164 164 db::blog_posts::update_blog_post(
165 165 &state.db,
@@ -80,7 +80,7 @@ pub(in crate::routes::api) async fn create_item(
80 80 let item_type = req.item_type.unwrap_or(ItemType::Digital);
81 81 let allowed = db::ProjectFeature::allowed_item_type_cards(&project.features);
82 82 if !allowed.iter().any(|(v, _, _)| *v == item_type.to_string().as_str()) {
83 - return Err(AppError::Validation(format!(
83 + return Err(AppError::validation(format!(
84 84 "Item type '{}' is not available for this project's features",
85 85 item_type
86 86 )));
@@ -209,7 +209,7 @@ pub(in crate::routes::api) async fn update_item(
209 209 AiTier::Assisted => {
210 210 let text = req.ai_disclosure.as_deref().unwrap_or("").trim();
211 211 if text.is_empty() {
212 - return Err(AppError::Validation(
212 + return Err(AppError::validation(
213 213 "AI disclosure is required for Assisted tier items".to_string(),
214 214 ));
215 215 }
@@ -87,7 +87,7 @@ pub(in crate::routes::api) async fn create_section(
87 87 // Enforce max sections limit
88 88 let count = db::item_sections::count_by_item(&state.db, item_id).await?;
89 89 if count >= MAX_SECTIONS_PER_ITEM {
90 - return Err(AppError::Validation(format!(
90 + return Err(AppError::validation(format!(
91 91 "Maximum of {} sections per item",
92 92 MAX_SECTIONS_PER_ITEM
93 93 )));
@@ -47,15 +47,15 @@ pub(in crate::routes::api) async fn add_tag(
47 47
48 48 // Only leaf tags (depth >= 3) are assignable to items
49 49 if tagtree::depth(&tag.slug) < 3 {
50 - return Err(AppError::Validation(
51 - "Only leaf tags (type.category.value) can be assigned to items".into(),
50 + return Err(AppError::validation(
51 + "Only leaf tags (type.category.value) can be assigned to items",
52 52 ));
53 53 }
54 54
55 55 // Enforce per-item tag limit
56 56 let existing = db::tags::get_tags_for_item(&state.db, id).await?;
57 57 if existing.len() >= MAX_TAGS_PER_ITEM && !existing.iter().any(|t| t.tag_id == form.tag_id) {
58 - return Err(AppError::Validation(
58 + return Err(AppError::validation(
59 59 format!("Items may have at most {MAX_TAGS_PER_ITEM} tags"),
60 60 ));
61 61 }
@@ -316,13 +316,13 @@ pub(super) async fn update_license_settings(
316 316 // Validate preset key
317 317 use crate::license_templates::LicensePreset;
318 318 let preset: LicensePreset = preset_key.parse().map_err(|_| {
319 - crate::error::AppError::Validation("Invalid license preset".into())
319 + crate::error::AppError::validation("Invalid license preset")
320 320 })?;
321 321 let custom_text = if preset == LicensePreset::Custom {
322 322 let text = req.custom_license_text.as_deref().unwrap_or("").trim();
323 323 if text.is_empty() {
324 - return Err(crate::error::AppError::Validation(
325 - "Custom license text is required when using Custom preset".into(),
324 + return Err(crate::error::AppError::validation(
325 + "Custom license text is required when using Custom preset",
326 326 ));
327 327 }
328 328 Some(text)
@@ -171,7 +171,7 @@ pub(super) async fn rename(
171 171 ) -> Result<Response> {
172 172 let name = form.name.trim();
173 173 if name.is_empty() || name.len() > 100 {
174 - return Err(AppError::Validation("Name must be 1-100 characters".to_string()));
174 + return Err(AppError::validation("Name must be 1-100 characters".to_string()));
175 175 }
176 176
177 177 if !db::passkeys::rename_passkey(&state.db, id, user.id, name).await? {
@@ -82,7 +82,7 @@ pub(super) async fn create_section(
82 82
83 83 let count = db::project_sections::count_by_project(&state.db, project_id).await?;
84 84 if count >= MAX_SECTIONS_PER_PROJECT {
85 - return Err(AppError::Validation(format!(
85 + return Err(AppError::validation(format!(
86 86 "Maximum of {} sections per project",
87 87 MAX_SECTIONS_PER_PROJECT
88 88 )));