All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m12s
The root cause was that drag operations sent naive local time to the backend, which the backend interpreted using the SERVER's local timezone rather than the USER's timezone. This caused different behavior between development and production servers in different timezones. **Frontend Changes:** - Convert naive datetime from drag operations to UTC before sending to backend - Use client-side Local timezone to properly convert user's intended times - Handle DST transition edge cases with fallback logic **Backend Changes:** - Update parse_event_datetime to treat incoming times as UTC (no server timezone conversion) - Update series handlers to expect UTC times from frontend - Remove server-side Local timezone dependency for event parsing **Result:** - Consistent behavior across all server environments regardless of server timezone - Drag operations now correctly preserve user's intended local times - Fixes "4 hours too early" issue in production drag-and-drop operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
873 lines
32 KiB
Rust
873 lines
32 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::HeaderMap,
|
|
response::Json,
|
|
};
|
|
use chrono::Datelike;
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
|
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
|
use crate::{
|
|
models::{
|
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
|
UpdateEventRequest, UpdateEventResponse,
|
|
},
|
|
AppState,
|
|
};
|
|
use calendar_models::{
|
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
|
};
|
|
|
|
use super::auth::{extract_bearer_token, extract_password_header};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CalendarQuery {
|
|
pub year: Option<i32>,
|
|
pub month: Option<u32>,
|
|
}
|
|
|
|
pub async fn get_calendar_events(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(params): Query<CalendarQuery>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
println!("🔑 API call with password length: {}", password.len());
|
|
|
|
// Create CalDAV config from token and password
|
|
let config = state
|
|
.auth_service
|
|
.caldav_config_from_token(&token, &password)?;
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Discover calendars if needed
|
|
let calendar_paths = client
|
|
.discover_calendars()
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
|
|
if calendar_paths.is_empty() {
|
|
return Ok(Json(vec![])); // No calendars found
|
|
}
|
|
|
|
// Fetch events from all calendars
|
|
let mut all_events = Vec::new();
|
|
for calendar_path in &calendar_paths {
|
|
match client.fetch_events(calendar_path).await {
|
|
Ok(mut events) => {
|
|
// Set calendar_path for each event to identify which calendar it belongs to
|
|
for event in &mut events {
|
|
event.calendar_path = Some(calendar_path.clone());
|
|
}
|
|
all_events.extend(events);
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Failed to fetch events from calendar {}: {}",
|
|
calendar_path, e
|
|
);
|
|
// Continue with other calendars instead of failing completely
|
|
}
|
|
}
|
|
}
|
|
|
|
// If year and month are specified, filter events
|
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
|
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
|
let month_start = target_date;
|
|
let month_end = if month == 12 {
|
|
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
|
} else {
|
|
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
|
} - chrono::Duration::days(1);
|
|
|
|
all_events.retain(|event| {
|
|
let event_date = event.dtstart.date_naive();
|
|
|
|
// For non-recurring events, check if the event date is within the month
|
|
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
|
let event_year = event.dtstart.year();
|
|
let event_month = event.dtstart.month();
|
|
return event_year == year && event_month == month;
|
|
}
|
|
|
|
// For recurring events, check if they could have instances in this month
|
|
// Include if:
|
|
// 1. The event starts before or during the requested month
|
|
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
|
|
if event_date > month_end {
|
|
// Event starts after the requested month
|
|
return false;
|
|
}
|
|
|
|
// Check UNTIL date in RRULE if present
|
|
if let Some(ref rrule) = event.rrule {
|
|
if let Some(until_pos) = rrule.find("UNTIL=") {
|
|
let until_part = &rrule[until_pos + 6..];
|
|
let until_end = until_part.find(';').unwrap_or(until_part.len());
|
|
let until_str = &until_part[..until_end];
|
|
|
|
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
|
|
if until_str.len() >= 8 {
|
|
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
|
|
if until_date < month_start {
|
|
// Recurring event ended before the requested month
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include the recurring event - the frontend will do proper expansion
|
|
true
|
|
});
|
|
}
|
|
|
|
println!("📅 Returning {} events", all_events.len());
|
|
Ok(Json(all_events))
|
|
}
|
|
|
|
pub async fn refresh_event(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(uid): Path<String>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Create CalDAV config from token and password
|
|
let config = state
|
|
.auth_service
|
|
.caldav_config_from_token(&token, &password)?;
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Discover calendars
|
|
let calendar_paths = client
|
|
.discover_calendars()
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
|
|
// Search for the event by UID across all calendars
|
|
for calendar_path in &calendar_paths {
|
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
|
event.calendar_path = Some(calendar_path.clone());
|
|
return Ok(Json(Some(event)));
|
|
}
|
|
}
|
|
|
|
Ok(Json(None))
|
|
}
|
|
|
|
async fn fetch_event_by_href(
|
|
client: &CalDAVClient,
|
|
calendar_path: &str,
|
|
event_href: &str,
|
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
|
let events = client.fetch_events(calendar_path).await?;
|
|
|
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
|
println!(
|
|
"🔍 Available events with hrefs: {:?}",
|
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
|
);
|
|
|
|
// First try to match by exact href
|
|
for event in &events {
|
|
if let Some(stored_href) = &event.href {
|
|
if stored_href == event_href {
|
|
println!("✅ Found matching event by exact href: {}", event.uid);
|
|
return Ok(Some(event.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try to match by UID extracted from href filename
|
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
|
let uid_from_href = filename.trim_end_matches(".ics");
|
|
|
|
println!(
|
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
|
filename, uid_from_href
|
|
);
|
|
|
|
for event in events {
|
|
if event.uid == uid_from_href {
|
|
println!("✅ Found matching event by UID: {}", event.uid);
|
|
return Ok(Some(event));
|
|
}
|
|
}
|
|
|
|
println!("❌ No matching event found for href: {}", event_href);
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn delete_event(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<DeleteEventRequest>,
|
|
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Create CalDAV config from token and password
|
|
let config = state
|
|
.auth_service
|
|
.caldav_config_from_token(&token, &password)?;
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Handle different delete actions for recurring events
|
|
match request.delete_action.as_str() {
|
|
"delete_this" => {
|
|
if let Some(event) =
|
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
|
{
|
|
// Check if this is a recurring event
|
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
|
// Recurring event - add EXDATE for this occurrence
|
|
if let Some(occurrence_date) = &request.occurrence_date {
|
|
let exception_utc = if let Ok(date) =
|
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
|
{
|
|
// RFC3339 format (with time and timezone)
|
|
date.with_timezone(&chrono::Utc)
|
|
} else if let Ok(naive_date) =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
|
{
|
|
// Simple date format (YYYY-MM-DD)
|
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
|
} else {
|
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
|
};
|
|
|
|
let mut updated_event = event;
|
|
updated_event.exdate.push(exception_utc);
|
|
|
|
println!(
|
|
"🔄 Adding EXDATE {} to recurring event {}",
|
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
|
updated_event.uid
|
|
);
|
|
|
|
// Update the event with the new EXDATE
|
|
client
|
|
.update_event(
|
|
&request.calendar_path,
|
|
&updated_event,
|
|
&request.event_href,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!(
|
|
"Failed to update event with EXDATE: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
println!("✅ Successfully updated recurring event with EXDATE");
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Single occurrence deleted successfully".to_string(),
|
|
}))
|
|
} else {
|
|
Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string()))
|
|
}
|
|
} else {
|
|
// Non-recurring event - delete the entire event
|
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
|
|
|
client
|
|
.delete_event(&request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
|
})?;
|
|
|
|
println!("✅ Successfully deleted non-recurring event");
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Event deleted successfully".to_string(),
|
|
}))
|
|
}
|
|
} else {
|
|
Err(ApiError::NotFound("Event not found".to_string()))
|
|
}
|
|
}
|
|
"delete_following" => {
|
|
// For "this and following" deletion, we need to:
|
|
// 1. Fetch the recurring event
|
|
// 2. Modify the RRULE to end before this occurrence
|
|
// 3. Update the event
|
|
|
|
if let Some(mut event) =
|
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
|
{
|
|
if let Some(occurrence_date) = &request.occurrence_date {
|
|
let until_date = if let Ok(date) =
|
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
|
{
|
|
// RFC3339 format (with time and timezone)
|
|
date.with_timezone(&chrono::Utc)
|
|
} else if let Ok(naive_date) =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
|
{
|
|
// Simple date format (YYYY-MM-DD)
|
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
|
} else {
|
|
return Err(ApiError::BadRequest(format!(
|
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
|
occurrence_date
|
|
)));
|
|
};
|
|
|
|
// Modify the RRULE to add an UNTIL clause
|
|
if let Some(rrule) = &event.rrule {
|
|
// Remove existing UNTIL if present and add new one
|
|
let parts: Vec<&str> = rrule
|
|
.split(';')
|
|
.filter(|part| {
|
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
|
})
|
|
.collect();
|
|
|
|
let new_rrule = format!(
|
|
"{};UNTIL={}",
|
|
parts.join(";"),
|
|
until_date.format("%Y%m%dT%H%M%SZ")
|
|
);
|
|
event.rrule = Some(new_rrule);
|
|
|
|
// Update the event with the modified RRULE
|
|
client
|
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!(
|
|
"Failed to update event with modified RRULE: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "This and following occurrences deleted successfully"
|
|
.to_string(),
|
|
}))
|
|
} else {
|
|
// No RRULE, just delete the single event
|
|
client
|
|
.delete_event(&request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
|
})?;
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Event deleted successfully".to_string(),
|
|
}))
|
|
}
|
|
} else {
|
|
Err(ApiError::BadRequest(
|
|
"Occurrence date is required for following deletion".to_string(),
|
|
))
|
|
}
|
|
} else {
|
|
Err(ApiError::NotFound("Event not found".to_string()))
|
|
}
|
|
}
|
|
"delete_series" | _ => {
|
|
// Delete the entire event/series
|
|
client
|
|
.delete_event(&request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Event deleted successfully".to_string(),
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn create_event(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<CreateEventRequest>,
|
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
|
println!(
|
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
|
request.title, request.all_day, request.calendar_path
|
|
);
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.title.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
|
}
|
|
|
|
if request.title.len() > 200 {
|
|
return Err(ApiError::BadRequest(
|
|
"Event title too long (max 200 characters)".to_string(),
|
|
));
|
|
}
|
|
|
|
// Create CalDAV config from token and password
|
|
let config = state
|
|
.auth_service
|
|
.caldav_config_from_token(&token, &password)?;
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Determine which calendar to use
|
|
let calendar_path = if let Some(path) = request.calendar_path {
|
|
path
|
|
} else {
|
|
// Use the first available calendar
|
|
let calendar_paths = client
|
|
.discover_calendars()
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
|
|
if calendar_paths.is_empty() {
|
|
return Err(ApiError::BadRequest(
|
|
"No calendars available for event creation".to_string(),
|
|
));
|
|
}
|
|
|
|
calendar_paths[0].clone()
|
|
};
|
|
|
|
// Parse dates and times
|
|
let start_datetime =
|
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
|
|
|
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
|
|
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
|
// RFC-5545 uses exclusive end dates for all-day events
|
|
if request.all_day {
|
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
}
|
|
|
|
// Validate that end is after start (allow equal times for all-day events)
|
|
if request.all_day {
|
|
if end_datetime < start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date must be on or after start date for all-day events".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
if end_datetime <= start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date/time must be after start date/time".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Generate a unique UID for the event
|
|
let uid = format!(
|
|
"{}-{}",
|
|
uuid::Uuid::new_v4(),
|
|
chrono::Utc::now().timestamp()
|
|
);
|
|
|
|
// Parse status
|
|
let status = match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
};
|
|
|
|
// Parse class
|
|
let class = match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
};
|
|
|
|
// Parse attendees (comma-separated email list)
|
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
request
|
|
.attendees
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
};
|
|
|
|
// Parse categories (comma-separated list)
|
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
request
|
|
.categories
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
};
|
|
|
|
// Parse alarms - convert from minutes string to EventReminder structs
|
|
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
match request.reminder.parse::<i32>() {
|
|
Ok(minutes) => vec![crate::calendar::EventReminder {
|
|
minutes_before: minutes,
|
|
action: crate::calendar::ReminderAction::Display,
|
|
description: None,
|
|
}],
|
|
Err(_) => Vec::new(),
|
|
}
|
|
};
|
|
|
|
// Check if recurrence is already a full RRULE or just a simple type
|
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
|
// Frontend sent a complete RRULE string, use it directly
|
|
if request.recurrence.is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.recurrence.clone())
|
|
}
|
|
} else {
|
|
// Legacy path: Parse recurrence with BYDAY support for weekly recurrence
|
|
match request.recurrence.to_uppercase().as_str() {
|
|
"DAILY" => Some("FREQ=DAILY".to_string()),
|
|
"WEEKLY" => {
|
|
// Handle weekly recurrence with optional BYDAY parameter
|
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
|
|
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
|
if request.recurrence_days.len() == 7 {
|
|
let selected_days: Vec<&str> = request
|
|
.recurrence_days
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, &selected)| {
|
|
if selected {
|
|
Some(match i {
|
|
0 => "SU", // Sunday
|
|
1 => "MO", // Monday
|
|
2 => "TU", // Tuesday
|
|
3 => "WE", // Wednesday
|
|
4 => "TH", // Thursday
|
|
5 => "FR", // Friday
|
|
6 => "SA", // Saturday
|
|
_ => return None,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !selected_days.is_empty() {
|
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
|
}
|
|
}
|
|
|
|
Some(rrule)
|
|
}
|
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
|
_ => None,
|
|
}
|
|
};
|
|
|
|
// Create the VEvent struct (RFC 5545 compliant)
|
|
let mut event = VEvent::new(uid, start_datetime);
|
|
event.dtend = Some(end_datetime);
|
|
event.summary = if request.title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.title.clone())
|
|
};
|
|
event.description = if request.description.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.description)
|
|
};
|
|
event.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location)
|
|
};
|
|
event.status = Some(status);
|
|
event.class = Some(class);
|
|
event.priority = request.priority;
|
|
event.organizer = if request.organizer.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(CalendarUser {
|
|
cal_address: request.organizer,
|
|
common_name: None,
|
|
dir_entry_ref: None,
|
|
sent_by: None,
|
|
language: None,
|
|
})
|
|
};
|
|
event.attendees = attendees
|
|
.into_iter()
|
|
.map(|email| Attendee {
|
|
cal_address: email,
|
|
common_name: None,
|
|
role: None,
|
|
part_stat: None,
|
|
rsvp: None,
|
|
cu_type: None,
|
|
member: Vec::new(),
|
|
delegated_to: Vec::new(),
|
|
delegated_from: Vec::new(),
|
|
sent_by: None,
|
|
dir_entry_ref: None,
|
|
language: None,
|
|
})
|
|
.collect();
|
|
event.categories = categories;
|
|
event.rrule = rrule;
|
|
event.all_day = request.all_day;
|
|
event.alarms = alarms
|
|
.into_iter()
|
|
.map(|reminder| VAlarm {
|
|
action: AlarmAction::Display,
|
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
|
-reminder.minutes_before as i64,
|
|
)),
|
|
duration: None,
|
|
repeat: None,
|
|
description: reminder.description,
|
|
summary: None,
|
|
attendees: Vec::new(),
|
|
attach: Vec::new(),
|
|
})
|
|
.collect();
|
|
event.calendar_path = Some(calendar_path.clone());
|
|
|
|
// Create the event on the CalDAV server
|
|
let event_href = client
|
|
.create_event(&calendar_path, &event)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
|
|
|
println!(
|
|
"✅ Event created successfully with UID: {} at href: {}",
|
|
event.uid, event_href
|
|
);
|
|
|
|
Ok(Json(CreateEventResponse {
|
|
success: true,
|
|
message: "Event created successfully".to_string(),
|
|
event_href: Some(event_href),
|
|
}))
|
|
}
|
|
|
|
pub async fn update_event(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<UpdateEventRequest>,
|
|
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
|
// Handle update request
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.uid.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
|
}
|
|
|
|
if request.title.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
|
}
|
|
|
|
if request.title.len() > 200 {
|
|
return Err(ApiError::BadRequest(
|
|
"Event title too long (max 200 characters)".to_string(),
|
|
));
|
|
}
|
|
|
|
// Create CalDAV config from token and password
|
|
let config = state
|
|
.auth_service
|
|
.caldav_config_from_token(&token, &password)?;
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Find the event across all calendars (or in the specified calendar)
|
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
|
vec![path.clone()]
|
|
} else {
|
|
client
|
|
.discover_calendars()
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
|
};
|
|
|
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
|
|
|
for calendar_path in &calendar_paths {
|
|
match client.fetch_events(calendar_path).await {
|
|
Ok(events) => {
|
|
for event in events {
|
|
if event.uid == request.uid {
|
|
// Use the actual href from the event, or generate one if missing
|
|
let event_href = event
|
|
.href
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
|
found_event = Some((event, calendar_path.clone(), event_href));
|
|
break;
|
|
}
|
|
}
|
|
if found_event.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Failed to fetch events from calendar {}: {}",
|
|
calendar_path, e
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let (mut event, calendar_path, event_href) = found_event
|
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
|
|
|
// Parse dates and times
|
|
let start_datetime =
|
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
|
|
|
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
|
|
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
|
// RFC-5545 uses exclusive end dates for all-day events
|
|
if request.all_day {
|
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
}
|
|
|
|
// Validate that end is after start (allow equal times for all-day events)
|
|
if request.all_day {
|
|
if end_datetime < start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date must be on or after start date for all-day events".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
if end_datetime <= start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date/time must be after start date/time".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Update event properties
|
|
event.dtstart = start_datetime;
|
|
event.dtend = Some(end_datetime);
|
|
event.summary = if request.title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.title)
|
|
};
|
|
event.description = if request.description.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.description)
|
|
};
|
|
event.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location)
|
|
};
|
|
event.all_day = request.all_day;
|
|
|
|
// Parse and update status
|
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
// Parse and update class
|
|
event.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
event.priority = request.priority;
|
|
|
|
// Update the event on the CalDAV server
|
|
println!(
|
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
|
event.uid, calendar_path, event_href
|
|
);
|
|
client
|
|
.update_event(&calendar_path, &event, &event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
|
|
|
println!("✅ Successfully updated event {}", event.uid);
|
|
|
|
Ok(Json(UpdateEventResponse {
|
|
success: true,
|
|
message: "Event updated successfully".to_string(),
|
|
}))
|
|
}
|
|
|
|
fn parse_event_datetime(
|
|
date_str: &str,
|
|
time_str: &str,
|
|
all_day: bool,
|
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
|
|
|
// Parse the date
|
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
|
|
|
if all_day {
|
|
// For all-day events, use noon UTC to avoid timezone boundary issues
|
|
// This ensures the date remains correct when converted to any local timezone
|
|
let datetime = date
|
|
.and_hms_opt(12, 0, 0)
|
|
.ok_or_else(|| "Failed to create noon datetime".to_string())?;
|
|
Ok(Utc.from_utc_datetime(&datetime))
|
|
} else {
|
|
// Parse the time
|
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
|
|
|
// Combine date and time
|
|
let datetime = NaiveDateTime::new(date, time);
|
|
|
|
// Frontend now sends UTC times, so treat as UTC directly
|
|
Ok(Utc.from_utc_datetime(&datetime))
|
|
}
|
|
}
|