use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize, Deserializer}; use std::fmt; /// Timezone-aware timestamp from v2 API #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TimezoneTimestamp { pub utc: DateTime, pub local: String, // "2025-08-13T05:00:00-04:00" pub timezone: String, // "America/New_York" } /// Custom deserializer that handles both v1 (simple string) and v2 (timezone object) formats fn deserialize_flexible_datetime<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde::de::{self, Visitor}; struct FlexibleDateTimeVisitor; impl<'de> Visitor<'de> for FlexibleDateTimeVisitor { type Value = DateTime; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a string timestamp or timezone object") } fn visit_str(self, value: &str) -> Result where E: de::Error, { // v1 format: simple ISO string DateTime::parse_from_rfc3339(value) .map(|dt| dt.with_timezone(&Utc)) .map_err(de::Error::custom) } fn visit_map(self, mut map: M) -> Result where M: de::MapAccess<'de>, { // v2 format: timezone object - extract UTC field let mut utc_value: Option> = None; while let Some(key) = map.next_key::()? { match key.as_str() { "utc" => { utc_value = Some(map.next_value()?); } _ => { // Skip other fields (local, timezone) let _: serde_json::Value = map.next_value()?; } } } utc_value.ok_or_else(|| de::Error::missing_field("utc")) } } deserializer.deserialize_any(FlexibleDateTimeVisitor) } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Event { pub id: String, pub title: String, pub description: String, #[serde(deserialize_with = "deserialize_flexible_datetime")] pub start_time: DateTime, #[serde(deserialize_with = "deserialize_flexible_datetime")] pub end_time: DateTime, pub location: String, #[serde(skip_serializing_if = "Option::is_none")] pub location_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail: Option, pub category: EventCategory, #[serde(default)] pub is_featured: bool, #[serde(skip_serializing_if = "Option::is_none")] pub recurring_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub contact_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub contact_phone: Option, #[serde(skip_serializing_if = "Option::is_none")] pub registration_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_attendees: Option, #[serde(skip_serializing_if = "Option::is_none")] pub current_attendees: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timezone: Option, #[serde(skip_serializing_if = "Option::is_none")] pub approved_from: Option, #[serde(deserialize_with = "deserialize_flexible_datetime")] pub created_at: DateTime, #[serde(deserialize_with = "deserialize_flexible_datetime")] pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct NewEvent { pub title: String, pub description: String, pub start_time: DateTime, pub end_time: DateTime, pub location: String, #[serde(skip_serializing_if = "Option::is_none")] pub location_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, pub category: EventCategory, #[serde(default)] pub is_featured: bool, #[serde(skip_serializing_if = "Option::is_none")] pub recurring_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub contact_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub contact_phone: Option, #[serde(skip_serializing_if = "Option::is_none")] pub registration_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_attendees: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct EventUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start_time: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub end_time: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, #[serde(skip_serializing_if = "Option::is_none")] pub location_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(skip_serializing_if = "Option::is_none")] pub category: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_featured: Option, #[serde(skip_serializing_if = "Option::is_none")] pub recurring_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub contact_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub contact_phone: Option, #[serde(skip_serializing_if = "Option::is_none")] pub registration_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_attendees: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum EventCategory { #[serde(rename = "service", alias = "Service")] Service, #[serde(rename = "ministry", alias = "Ministry")] Ministry, #[serde(rename = "social", alias = "Social")] Social, #[serde(rename = "education", alias = "Education")] Education, #[serde(rename = "outreach", alias = "Outreach")] Outreach, #[serde(rename = "youth", alias = "Youth")] Youth, #[serde(rename = "music", alias = "Music")] Music, #[serde(rename = "other", alias = "Other")] Other, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum RecurringType { #[serde(rename = "daily", alias = "DAILY")] Daily, #[serde(rename = "weekly", alias = "WEEKLY")] Weekly, #[serde(rename = "biweekly", alias = "BIWEEKLY")] Biweekly, #[serde(rename = "monthly", alias = "MONTHLY")] Monthly, #[serde(rename = "first_tuesday", alias = "FIRST_TUESDAY")] FirstTuesday, #[serde(rename = "first_sabbath", alias = "FIRST_SABBATH")] FirstSabbath, #[serde(rename = "last_sabbath", alias = "LAST_SABBATH")] LastSabbath, #[serde(rename = "2nd/3rd Saturday Monthly")] SecondThirdSaturday, } impl Event { pub fn duration_minutes(&self) -> i64 { (self.end_time - self.start_time).num_minutes() } pub fn has_registration(&self) -> bool { self.registration_url.is_some() } pub fn is_full(&self) -> bool { match (self.max_attendees, self.current_attendees) { (Some(max), Some(current)) => current >= max, _ => false, } } pub fn spots_remaining(&self) -> Option { match (self.max_attendees, self.current_attendees) { (Some(max), Some(current)) => Some(max.saturating_sub(current)), _ => None, } } } impl fmt::Display for EventCategory { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EventCategory::Service => write!(f, "Service"), EventCategory::Ministry => write!(f, "Ministry"), EventCategory::Social => write!(f, "Social"), EventCategory::Education => write!(f, "Education"), EventCategory::Outreach => write!(f, "Outreach"), EventCategory::Youth => write!(f, "Youth"), EventCategory::Music => write!(f, "Music"), EventCategory::Other => write!(f, "Other"), } } } impl Event { pub fn formatted_date(&self) -> String { self.start_time.format("%A, %B %d, %Y").to_string() } /// Returns formatted date range for multi-day events, single date for same-day events pub fn formatted_date_range(&self) -> String { let start_date = self.start_time.date_naive(); let end_date = self.end_time.date_naive(); if start_date == end_date { // Same day event self.start_time.format("%A, %B %d, %Y").to_string() } else { // Multi-day event let start_formatted = self.start_time.format("%A, %B %d, %Y").to_string(); let end_formatted = self.end_time.format("%A, %B %d, %Y").to_string(); format!("{} - {}", start_formatted, end_formatted) } } pub fn formatted_start_time(&self) -> String { // Convert UTC to user's local timezone automatically let local_time = self.start_time.with_timezone(&chrono::Local); local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string() } pub fn formatted_end_time(&self) -> String { // Convert UTC to user's local timezone automatically let local_time = self.end_time.with_timezone(&chrono::Local); local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string() } pub fn clean_description(&self) -> String { html2text::from_read(self.description.as_bytes(), 80) .replace('\n', " ") .split_whitespace() .collect::>() .join(" ") } } /// Event submission for public submission endpoint #[derive(Debug, Serialize, Deserialize, Clone)] pub struct EventSubmission { pub title: String, pub description: String, pub start_time: String, // ISO string format pub end_time: String, // ISO string format pub location: String, #[serde(skip_serializing_if = "Option::is_none")] pub location_url: Option, pub category: String, // String to match API exactly #[serde(default)] pub is_featured: bool, #[serde(skip_serializing_if = "Option::is_none")] pub recurring_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bulletin_week: Option, // Date string in YYYY-MM-DD format pub submitter_email: String, } impl EventSubmission { /// Parse start_time string to DateTime pub fn parse_start_time(&self) -> Option> { crate::utils::parse_datetime_flexible(&self.start_time) } /// Parse end_time string to DateTime pub fn parse_end_time(&self) -> Option> { crate::utils::parse_datetime_flexible(&self.end_time) } /// Validate that both start and end times can be parsed pub fn validate_times(&self) -> bool { self.parse_start_time().is_some() && self.parse_end_time().is_some() } } /// Pending event for admin management #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PendingEvent { pub id: String, pub title: String, pub description: String, pub start_time: DateTime, pub end_time: DateTime, pub location: String, #[serde(skip_serializing_if = "Option::is_none")] pub location_url: Option, pub category: EventCategory, #[serde(default)] pub is_featured: bool, #[serde(skip_serializing_if = "Option::is_none")] pub recurring_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bulletin_week: Option, pub submitter_email: String, pub created_at: DateTime, pub updated_at: DateTime, }