349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
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<Utc>,
|
|
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<DateTime<Utc>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
use serde::de::{self, Visitor};
|
|
|
|
struct FlexibleDateTimeVisitor;
|
|
|
|
impl<'de> Visitor<'de> for FlexibleDateTimeVisitor {
|
|
type Value = DateTime<Utc>;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("a string timestamp or timezone object")
|
|
}
|
|
|
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
|
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<M>(self, mut map: M) -> Result<Self::Value, M::Error>
|
|
where
|
|
M: de::MapAccess<'de>,
|
|
{
|
|
// v2 format: timezone object - extract UTC field
|
|
let mut utc_value: Option<DateTime<Utc>> = None;
|
|
|
|
while let Some(key) = map.next_key::<String>()? {
|
|
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<Utc>,
|
|
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
|
pub end_time: DateTime<Utc>,
|
|
pub location: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub location_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub image: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub thumbnail: Option<String>,
|
|
pub category: EventCategory,
|
|
#[serde(default)]
|
|
pub is_featured: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub recurring_type: Option<RecurringType>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tags: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_email: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_phone: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub registration_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_attendees: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub current_attendees: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub timezone: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub approved_from: Option<String>,
|
|
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
|
pub created_at: DateTime<Utc>,
|
|
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct NewEvent {
|
|
pub title: String,
|
|
pub description: String,
|
|
pub start_time: DateTime<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub location: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub location_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub image: Option<String>,
|
|
pub category: EventCategory,
|
|
#[serde(default)]
|
|
pub is_featured: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub recurring_type: Option<RecurringType>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tags: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_email: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_phone: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub registration_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_attendees: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct EventUpdate {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub start_time: Option<DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub end_time: Option<DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub location: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub location_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub image: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub category: Option<EventCategory>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub is_featured: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub recurring_type: Option<RecurringType>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tags: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_email: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub contact_phone: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub registration_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_attendees: Option<u32>,
|
|
}
|
|
|
|
#[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<u32> {
|
|
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::<Vec<&str>>()
|
|
.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<String>,
|
|
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub bulletin_week: Option<String>, // Date string in YYYY-MM-DD format
|
|
pub submitter_email: String,
|
|
}
|
|
|
|
impl EventSubmission {
|
|
/// Parse start_time string to DateTime<Utc>
|
|
pub fn parse_start_time(&self) -> Option<DateTime<Utc>> {
|
|
crate::utils::parse_datetime_flexible(&self.start_time)
|
|
}
|
|
|
|
/// Parse end_time string to DateTime<Utc>
|
|
pub fn parse_end_time(&self) -> Option<DateTime<Utc>> {
|
|
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<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub location: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub location_url: Option<String>,
|
|
pub category: EventCategory,
|
|
#[serde(default)]
|
|
pub is_featured: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub recurring_type: Option<RecurringType>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub bulletin_week: Option<String>,
|
|
pub submitter_email: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
} |