The smokesignal.events web application
1//! Shared validation logic for event creation and editing.
2//!
3//! This module extracts common validation code used by both
4//! `handle_create_event_json` and `handle_edit_event_json`.
5
6use axum::Json;
7use axum::response::IntoResponse;
8use chrono::{DateTime, Utc};
9use http::StatusCode;
10use serde_json::json;
11use thiserror::Error;
12
13use atproto_identity::resolve::IdentityResolver;
14use atproto_record::lexicon::TypedBlob;
15use atproto_record::lexicon::app::bsky::richtext::facet::Facet;
16use atproto_record::lexicon::community::lexicon::calendar::event::{
17 AspectRatio, EventLink, Media, Mode, Status, TypedEventLink,
18};
19use atproto_record::lexicon::community::lexicon::location::{Address, Geo, LocationOrRef};
20use atproto_record::typed::TypedLexicon;
21
22use crate::atproto::utils::{location_from_address, location_from_geo};
23use crate::facets::{FacetLimits, parse_facets_from_text};
24use crate::http::event_form::{ManageEvent, ManageEventLink, ManageEventLocation};
25
26/// Validation error for event requests
27#[derive(Debug, Error)]
28pub(crate) enum EventValidationError {
29 #[error("Name must be between 10 and 500 characters")]
30 InvalidNameLength,
31
32 #[error("Description must be between 10 and 3000 characters")]
33 InvalidDescriptionLength,
34
35 #[error("Invalid start datetime format")]
36 InvalidStartDateTime,
37
38 #[error("Invalid end datetime format")]
39 InvalidEndDateTime,
40
41 #[error("Cannot set end time without start time")]
42 EndWithoutStart,
43
44 #[error("End time must be after start time")]
45 EndBeforeStart,
46
47 #[error("Invalid mode. Must be 'inperson', 'virtual', or 'hybrid'")]
48 InvalidMode,
49
50 #[error("Invalid status")]
51 InvalidStatus,
52
53 #[error("{0}")]
54 InvalidGeoLocation(&'static str),
55
56 #[error("Invalid header blob: {0}")]
57 InvalidHeaderBlob(String),
58
59 #[error("Invalid thumbnail blob: {0}")]
60 InvalidThumbnailBlob(String),
61}
62
63impl EventValidationError {
64 /// Convert validation error to HTTP response
65 pub(crate) fn into_response(self) -> axum::response::Response {
66 (
67 StatusCode::BAD_REQUEST,
68 Json(json!({ "error": self.to_string() })),
69 )
70 .into_response()
71 }
72}
73
74/// Validated and parsed event data ready for AT Protocol submission
75pub(crate) struct ValidatedEventData {
76 pub name: String,
77 pub description: String,
78 pub starts_at: Option<DateTime<Utc>>,
79 pub ends_at: Option<DateTime<Utc>>,
80 pub mode: Option<Mode>,
81 pub status: Option<Status>,
82 pub locations: Vec<LocationOrRef>,
83 pub uris: Vec<TypedEventLink>,
84 pub media: Vec<TypedLexicon<Media>>,
85 pub facets: Option<Vec<Facet>>,
86}
87
88/// Validate name length (10-500 characters)
89pub(crate) fn validate_name(name: &str) -> Result<String, EventValidationError> {
90 let trimmed = name.trim();
91 if trimmed.len() < 10 || trimmed.len() > 500 {
92 return Err(EventValidationError::InvalidNameLength);
93 }
94 Ok(trimmed.to_string())
95}
96
97/// Validate description length (10-3000 characters)
98pub(crate) fn validate_description(description: &str) -> Result<String, EventValidationError> {
99 let trimmed = description.trim();
100 if trimmed.len() < 10 || trimmed.len() > 3000 {
101 return Err(EventValidationError::InvalidDescriptionLength);
102 }
103 Ok(trimmed.to_string())
104}
105
106/// Parse and validate start datetime
107pub(crate) fn parse_starts_at(
108 starts_str: Option<&str>,
109) -> Result<Option<DateTime<Utc>>, EventValidationError> {
110 match starts_str {
111 Some(s) => {
112 let dt = s
113 .parse::<DateTime<Utc>>()
114 .map_err(|_| EventValidationError::InvalidStartDateTime)?;
115 Ok(Some(dt))
116 }
117 None => Ok(None),
118 }
119}
120
121/// Parse and validate end datetime (empty strings treated as None)
122pub(crate) fn parse_ends_at(
123 ends_str: Option<&str>,
124) -> Result<Option<DateTime<Utc>>, EventValidationError> {
125 match ends_str {
126 Some(s) if !s.trim().is_empty() => {
127 let dt = s
128 .parse::<DateTime<Utc>>()
129 .map_err(|_| EventValidationError::InvalidEndDateTime)?;
130 Ok(Some(dt))
131 }
132 _ => Ok(None),
133 }
134}
135
136/// Validate that end is after start (if both are set)
137pub(crate) fn validate_time_range(
138 starts_at: Option<DateTime<Utc>>,
139 ends_at: Option<DateTime<Utc>>,
140) -> Result<(), EventValidationError> {
141 if let (Some(start), Some(end)) = (starts_at, ends_at)
142 && end <= start
143 {
144 return Err(EventValidationError::EndBeforeStart);
145 }
146 Ok(())
147}
148
149/// Validate that end time requires start time (for create only)
150pub(crate) fn validate_end_requires_start(
151 starts_at: Option<DateTime<Utc>>,
152 ends_at: Option<DateTime<Utc>>,
153) -> Result<(), EventValidationError> {
154 if ends_at.is_some() && starts_at.is_none() {
155 return Err(EventValidationError::EndWithoutStart);
156 }
157 Ok(())
158}
159
160/// Parse mode string to Mode enum
161pub(crate) fn parse_mode(mode_str: &str) -> Result<Option<Mode>, EventValidationError> {
162 match mode_str {
163 "inperson" => Ok(Some(Mode::InPerson)),
164 "virtual" => Ok(Some(Mode::Virtual)),
165 "hybrid" => Ok(Some(Mode::Hybrid)),
166 _ => Err(EventValidationError::InvalidMode),
167 }
168}
169
170/// Parse status string to Status enum
171pub(crate) fn parse_status(status_str: &str) -> Result<Option<Status>, EventValidationError> {
172 match status_str {
173 "planned" => Ok(Some(Status::Planned)),
174 "scheduled" => Ok(Some(Status::Scheduled)),
175 "cancelled" => Ok(Some(Status::Cancelled)),
176 "postponed" => Ok(Some(Status::Postponed)),
177 "rescheduled" => Ok(Some(Status::Rescheduled)),
178 _ => Err(EventValidationError::InvalidStatus),
179 }
180}
181
182/// Validate and convert locations from ManageEventLocation enum to AT Protocol format.
183///
184/// This function handles both Address and Geo variants in a single pass,
185/// validating geo coordinates and converting all locations to LocationOrRef.
186pub(crate) fn validate_and_convert_locations(
187 locations: &[ManageEventLocation],
188) -> Result<Vec<LocationOrRef>, EventValidationError> {
189 let mut result = Vec::with_capacity(locations.len());
190
191 for location in locations {
192 match location {
193 ManageEventLocation::Address {
194 country,
195 postal_code,
196 region,
197 locality,
198 street,
199 name,
200 } => {
201 result.push(location_from_address(Address {
202 country: country.clone(),
203 postal_code: postal_code.clone(),
204 region: region.clone(),
205 locality: locality.clone(),
206 street: street.clone(),
207 name: name.clone(),
208 }));
209 }
210 ManageEventLocation::Geo {
211 latitude,
212 longitude,
213 name,
214 } => {
215 // Validate latitude
216 let lat: f64 = latitude.parse().map_err(|_| {
217 EventValidationError::InvalidGeoLocation("Invalid latitude format")
218 })?;
219 if !(-90.0..=90.0).contains(&lat) {
220 return Err(EventValidationError::InvalidGeoLocation(
221 "Latitude must be between -90 and 90",
222 ));
223 }
224
225 // Validate longitude
226 let lon: f64 = longitude.parse().map_err(|_| {
227 EventValidationError::InvalidGeoLocation("Invalid longitude format")
228 })?;
229 if !(-180.0..=180.0).contains(&lon) {
230 return Err(EventValidationError::InvalidGeoLocation(
231 "Longitude must be between -180 and 180",
232 ));
233 }
234
235 result.push(location_from_geo(Geo {
236 latitude: latitude.clone(),
237 longitude: longitude.clone(),
238 name: name.clone(),
239 }));
240 }
241 }
242 }
243
244 Ok(result)
245}
246
247/// Convert links to AT Protocol format
248pub(crate) fn convert_links(links: &[ManageEventLink]) -> Vec<TypedEventLink> {
249 links
250 .iter()
251 .map(|link| {
252 TypedLexicon::new(EventLink {
253 uri: link.url.clone(),
254 name: link.label.clone(),
255 })
256 })
257 .collect()
258}
259
260/// Build media array from header and thumbnail CIDs
261pub(crate) fn build_media(
262 header_cid: Option<&str>,
263 header_alt: Option<&str>,
264 header_size: Option<i64>,
265 thumbnail_cid: Option<&str>,
266 thumbnail_alt: Option<&str>,
267) -> Result<Vec<TypedLexicon<Media>>, EventValidationError> {
268 let mut media = Vec::new();
269
270 if let Some(cid) = header_cid {
271 let blob_json = serde_json::json!({
272 "$type": "blob",
273 "ref": {
274 "$link": cid
275 },
276 "mimeType": "image/png",
277 "size": header_size.unwrap_or(0)
278 });
279
280 let typed_blob: TypedBlob = serde_json::from_value(blob_json)
281 .map_err(|e| EventValidationError::InvalidHeaderBlob(e.to_string()))?;
282
283 media.push(TypedLexicon::new(Media {
284 role: "header".to_string(),
285 alt: header_alt.unwrap_or_default().to_string(),
286 content: typed_blob,
287 aspect_ratio: Some(AspectRatio {
288 width: 1500,
289 height: 500,
290 }),
291 }));
292 }
293
294 if let Some(cid) = thumbnail_cid {
295 let blob_json = serde_json::json!({
296 "$type": "blob",
297 "ref": {
298 "$link": cid
299 },
300 "mimeType": "image/png",
301 "size": 0
302 });
303
304 let typed_blob: TypedBlob = serde_json::from_value(blob_json)
305 .map_err(|e| EventValidationError::InvalidThumbnailBlob(e.to_string()))?;
306
307 media.push(TypedLexicon::new(Media {
308 role: "thumbnail".to_string(),
309 alt: thumbnail_alt.unwrap_or_default().to_string(),
310 content: typed_blob,
311 aspect_ratio: Some(AspectRatio {
312 width: 1024,
313 height: 1024,
314 }),
315 }));
316 }
317
318 Ok(media)
319}
320
321/// Validate and parse a complete event request.
322///
323/// This function validates all fields and converts them to AT Protocol types.
324/// Used by both create and edit event handlers.
325pub(crate) async fn validate_event_request(
326 request: &ManageEvent,
327 identity_resolver: &dyn IdentityResolver,
328 facet_limits: &FacetLimits,
329) -> Result<ValidatedEventData, EventValidationError> {
330 // Validate basic fields
331 let name = validate_name(&request.name)?;
332 let description = validate_description(&request.description)?;
333
334 // Parse and validate datetimes
335 let starts_at = parse_starts_at(request.starts_at.as_deref())?;
336 let ends_at = parse_ends_at(request.ends_at.as_deref())?;
337 validate_time_range(starts_at, ends_at)?;
338
339 // Parse mode and status
340 let mode = parse_mode(&request.mode)?;
341 let status = parse_status(&request.status)?;
342
343 // Validate and convert locations (handles both Address and Geo variants)
344 let locations = validate_and_convert_locations(&request.locations)?;
345
346 // Convert links
347 let uris = convert_links(&request.links);
348
349 // Build media
350 let media = build_media(
351 request.header_cid.as_deref(),
352 request.header_alt.as_deref(),
353 request.header_size,
354 request.thumbnail_cid.as_deref(),
355 request.thumbnail_alt.as_deref(),
356 )?;
357
358 // Parse facets from description
359 let facets = if !description.is_empty() {
360 parse_facets_from_text(&description, identity_resolver, facet_limits).await
361 } else {
362 None
363 };
364
365 Ok(ValidatedEventData {
366 name,
367 description,
368 starts_at,
369 ends_at,
370 mode,
371 status,
372 locations,
373 uris,
374 media,
375 facets,
376 })
377}
378
379/// Convert status enum to string for form data
380pub(crate) fn status_to_string(status: &Option<Status>) -> String {
381 match status {
382 Some(Status::Planned) => "planned",
383 Some(Status::Scheduled) => "scheduled",
384 Some(Status::Cancelled) => "cancelled",
385 Some(Status::Postponed) => "postponed",
386 Some(Status::Rescheduled) => "rescheduled",
387 None => "scheduled",
388 }
389 .to_string()
390}
391
392/// Convert mode enum to string for form data
393pub(crate) fn mode_to_string(mode: &Option<Mode>) -> String {
394 match mode {
395 Some(Mode::InPerson) => "inperson",
396 Some(Mode::Virtual) => "virtual",
397 Some(Mode::Hybrid) => "hybrid",
398 None => "inperson",
399 }
400 .to_string()
401}