The smokesignal.events web application
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}