The smokesignal.events web application
at main 265 lines 9.3 kB view raw
1//! Unified event form structures for create and edit operations. 2//! 3//! This module provides a single `ManageEvent` structure that serves both as 4//! template data (serialized to JSON for Alpine.js) and request data 5//! (deserialized from form submissions). 6 7use serde::{Deserialize, Serialize}; 8 9/// Default status for new events. 10fn default_status() -> String { 11 "scheduled".to_string() 12} 13 14/// Default mode for new events. 15fn default_mode() -> String { 16 "inperson".to_string() 17} 18 19/// Location for an event - either a physical address or GPS coordinates. 20/// 21/// This enum mirrors AT Protocol's `LocationOrRef` structure, where a location 22/// can be one of several types. Using an enum correctly models the "one of" 23/// relationship - a location IS either an address OR coordinates. 24#[derive(Debug, Clone, Serialize, Deserialize)] 25#[serde(tag = "type")] 26pub(crate) enum ManageEventLocation { 27 /// Physical address location 28 #[serde(rename = "address", rename_all = "camelCase")] 29 Address { 30 country: String, 31 #[serde(default, skip_serializing_if = "Option::is_none")] 32 postal_code: Option<String>, 33 #[serde(default, skip_serializing_if = "Option::is_none")] 34 region: Option<String>, 35 #[serde(default, skip_serializing_if = "Option::is_none")] 36 locality: Option<String>, 37 #[serde(default, skip_serializing_if = "Option::is_none")] 38 street: Option<String>, 39 #[serde(default, skip_serializing_if = "Option::is_none")] 40 name: Option<String>, 41 }, 42 /// GPS coordinates location 43 #[serde(rename = "geo", rename_all = "camelCase")] 44 Geo { 45 latitude: String, 46 longitude: String, 47 #[serde(default, skip_serializing_if = "Option::is_none")] 48 name: Option<String>, 49 }, 50} 51 52/// Link for an event (e.g., registration, livestream, etc.) 53#[derive(Debug, Default, Serialize, Deserialize, Clone)] 54#[serde(rename_all = "camelCase")] 55pub(crate) struct ManageEventLink { 56 pub url: String, 57 #[serde(default, skip_serializing_if = "Option::is_none")] 58 pub label: Option<String>, 59} 60 61/// Event configuration settings. 62/// 63/// These settings control RSVP behavior and notifications. 64/// Uses `#[serde(flatten)]` in `ManageEvent` to keep JSON structure flat. 65#[derive(Debug, Default, Serialize, Deserialize, Clone)] 66#[serde(rename_all = "camelCase")] 67pub(crate) struct EventConfiguration { 68 /// Require confirmed email addresses for RSVPs 69 #[serde(default)] 70 pub require_confirmed_email: bool, 71 72 /// Send notification to attendees when event is updated (edit mode only) 73 #[serde(default)] 74 pub send_notifications: bool, 75 76 /// Disable direct RSVPs and redirect to external ticketing URL 77 #[serde(default)] 78 pub disable_direct_rsvp: bool, 79 80 /// External ticketing URL (e.g., ti.to) for ticket purchases 81 #[serde(default, skip_serializing_if = "Option::is_none")] 82 pub rsvp_redirect_url: Option<String>, 83} 84 85/// Private content form data for the content management tab. 86/// 87/// This is separate from the event form itself since private content 88/// is stored off-protocol in a separate table. 89#[derive(Debug, Default, Serialize, Deserialize)] 90pub(crate) struct PrivateContentFormData { 91 pub private_content: Option<String>, 92 pub private_content_criteria_going_confirmed: Option<bool>, 93 pub private_content_criteria_going: Option<bool>, 94 pub private_content_criteria_interested: Option<bool>, 95} 96 97/// Unified event form data for creation and editing. 98/// 99/// This struct serves dual purposes: 100/// 1. **Template data**: Serialized to JSON for Alpine.js form initialization 101/// 2. **Request data**: Deserialized from form submissions 102/// 103/// The JSON structure uses camelCase to match JavaScript conventions. 104#[derive(Debug, Default, Serialize, Deserialize)] 105#[serde(rename_all = "camelCase")] 106pub(crate) struct ManageEvent { 107 // Core event fields 108 pub name: String, 109 pub description: String, 110 #[serde(default = "default_status")] 111 pub status: String, 112 #[serde(default = "default_mode")] 113 pub mode: String, 114 115 // Datetime fields 116 #[serde(default)] 117 pub tz: String, 118 #[serde(default, skip_serializing_if = "Option::is_none")] 119 pub starts_at: Option<String>, // ISO 8601 datetime string 120 #[serde(default, skip_serializing_if = "Option::is_none")] 121 pub ends_at: Option<String>, // ISO 8601 datetime string 122 123 // Locations - single array containing both address and geo variants 124 #[serde(default)] 125 pub locations: Vec<ManageEventLocation>, 126 127 // Links collection 128 #[serde(default)] 129 pub links: Vec<ManageEventLink>, 130 131 // Media fields 132 #[serde(default, skip_serializing_if = "Option::is_none")] 133 pub header_cid: Option<String>, 134 #[serde(default, skip_serializing_if = "Option::is_none")] 135 pub header_alt: Option<String>, 136 #[serde(default, skip_serializing_if = "Option::is_none")] 137 pub header_size: Option<i64>, 138 #[serde(default, skip_serializing_if = "Option::is_none")] 139 pub thumbnail_cid: Option<String>, 140 #[serde(default, skip_serializing_if = "Option::is_none")] 141 pub thumbnail_alt: Option<String>, 142 143 // Configuration (flattened to keep JSON structure flat) 144 #[serde(flatten)] 145 pub config: EventConfiguration, 146} 147 148#[cfg(test)] 149mod tests { 150 use super::*; 151 152 #[test] 153 fn test_manage_event_location_address_serialization() { 154 let loc = ManageEventLocation::Address { 155 country: "US".to_string(), 156 postal_code: Some("45402".to_string()), 157 region: Some("Ohio".to_string()), 158 locality: Some("Dayton".to_string()), 159 street: None, 160 name: Some("The Gem City".to_string()), 161 }; 162 163 let json = serde_json::to_string(&loc).unwrap(); 164 assert!(json.contains(r#""type":"address""#)); 165 assert!(json.contains(r#""country":"US""#)); 166 assert!(json.contains(r#""postalCode":"45402""#)); 167 assert!(!json.contains(r#""street""#)); // None values should be skipped 168 } 169 170 #[test] 171 fn test_manage_event_location_geo_serialization() { 172 let loc = ManageEventLocation::Geo { 173 latitude: "39.7066186".to_string(), 174 longitude: "-84.1702195".to_string(), 175 name: Some("Downtown".to_string()), 176 }; 177 178 let json = serde_json::to_string(&loc).unwrap(); 179 assert!(json.contains(r#""type":"geo""#)); 180 assert!(json.contains(r#""latitude":"39.7066186""#)); 181 assert!(json.contains(r#""longitude":"-84.1702195""#)); 182 } 183 184 #[test] 185 fn test_manage_event_location_address_deserialization() { 186 let json = r#"{"type":"address","country":"US","locality":"Dayton"}"#; 187 let loc: ManageEventLocation = serde_json::from_str(json).unwrap(); 188 189 match loc { 190 ManageEventLocation::Address { 191 country, locality, .. 192 } => { 193 assert_eq!(country, "US"); 194 assert_eq!(locality, Some("Dayton".to_string())); 195 } 196 _ => panic!("Expected Address variant"), 197 } 198 } 199 200 #[test] 201 fn test_manage_event_location_geo_deserialization() { 202 let json = r#"{"type":"geo","latitude":"39.7","longitude":"-84.1"}"#; 203 let loc: ManageEventLocation = serde_json::from_str(json).unwrap(); 204 205 match loc { 206 ManageEventLocation::Geo { 207 latitude, 208 longitude, 209 .. 210 } => { 211 assert_eq!(latitude, "39.7"); 212 assert_eq!(longitude, "-84.1"); 213 } 214 _ => panic!("Expected Geo variant"), 215 } 216 } 217 218 #[test] 219 fn test_manage_event_with_mixed_locations() { 220 let event = ManageEvent { 221 name: "Test Event".to_string(), 222 description: "A test event".to_string(), 223 locations: vec![ 224 ManageEventLocation::Address { 225 country: "US".to_string(), 226 postal_code: None, 227 region: None, 228 locality: Some("Dayton".to_string()), 229 street: None, 230 name: None, 231 }, 232 ManageEventLocation::Geo { 233 latitude: "39.7".to_string(), 234 longitude: "-84.1".to_string(), 235 name: None, 236 }, 237 ], 238 ..Default::default() 239 }; 240 241 let json = serde_json::to_string(&event).unwrap(); 242 assert!(json.contains(r#""type":"address""#)); 243 assert!(json.contains(r#""type":"geo""#)); 244 } 245 246 #[test] 247 fn test_event_configuration_flatten() { 248 let event = ManageEvent { 249 name: "Test".to_string(), 250 description: "Test".to_string(), 251 config: EventConfiguration { 252 require_confirmed_email: true, 253 send_notifications: false, 254 disable_direct_rsvp: false, 255 rsvp_redirect_url: None, 256 }, 257 ..Default::default() 258 }; 259 260 let json = serde_json::to_string(&event).unwrap(); 261 // Config fields should be at top level due to flatten 262 assert!(json.contains(r#""requireConfirmedEmail":true"#)); 263 assert!(json.contains(r#""sendNotifications":false"#)); 264 } 265}