RTSDA-Website/church-core/src/models/event.rs

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>,
}