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>
1199 lines
44 KiB
Rust
1199 lines
44 KiB
Rust
use axum::{extract::State, http::HeaderMap, response::Json};
|
|
use chrono::TimeZone;
|
|
use std::sync::Arc;
|
|
|
|
use crate::calendar::CalDAVClient;
|
|
use crate::{
|
|
models::{
|
|
ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, DeleteEventSeriesRequest,
|
|
DeleteEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse,
|
|
},
|
|
AppState,
|
|
};
|
|
use calendar_models::{EventClass, EventStatus, VEvent};
|
|
|
|
use super::auth::{extract_bearer_token, extract_password_header};
|
|
|
|
/// Create a new recurring event series
|
|
pub async fn create_event_series(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<CreateEventSeriesRequest>,
|
|
) -> Result<Json<CreateEventSeriesResponse>, ApiError> {
|
|
println!(
|
|
"📝 Create event series request received: title='{}', recurrence='{}', all_day={}",
|
|
request.title, request.recurrence, request.all_day
|
|
);
|
|
|
|
// 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(),
|
|
));
|
|
}
|
|
|
|
if request.recurrence == "none" {
|
|
return Err(ApiError::BadRequest(
|
|
"Use regular create endpoint for non-recurring events".to_string(),
|
|
));
|
|
}
|
|
|
|
// Validate recurrence type - handle both simple strings and RRULE strings
|
|
let recurrence_freq = if request.recurrence.contains("FREQ=") {
|
|
// Parse RRULE to extract frequency
|
|
if request.recurrence.contains("FREQ=DAILY") {
|
|
"daily"
|
|
} else if request.recurrence.contains("FREQ=WEEKLY") {
|
|
"weekly"
|
|
} else if request.recurrence.contains("FREQ=MONTHLY") {
|
|
"monthly"
|
|
} else if request.recurrence.contains("FREQ=YEARLY") {
|
|
"yearly"
|
|
} else {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
// Handle simple strings
|
|
let lower = request.recurrence.to_lowercase();
|
|
match lower.as_str() {
|
|
"daily" => "daily",
|
|
"weekly" => "weekly",
|
|
"monthly" => "monthly",
|
|
"yearly" => "yearly",
|
|
_ => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
|
.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(ref path) = request.calendar_path {
|
|
path.clone()
|
|
} 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()
|
|
};
|
|
|
|
println!("📅 Using calendar path: {}", calendar_path);
|
|
|
|
// Parse datetime components
|
|
let start_date =
|
|
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
|
})?;
|
|
|
|
let (start_datetime, end_datetime) = if request.all_day {
|
|
// For all-day events, use the dates as-is
|
|
let start_dt = start_date
|
|
.and_hms_opt(0, 0, 0)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
|
|
|
let end_date = if !request.end_date.is_empty() {
|
|
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
|
})?
|
|
} else {
|
|
start_date
|
|
};
|
|
|
|
let end_dt = end_date
|
|
.and_hms_opt(23, 59, 59)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
|
|
// Frontend now sends UTC times, so treat as UTC directly
|
|
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
|
|
(
|
|
start_local.with_timezone(&chrono::Utc),
|
|
end_local.with_timezone(&chrono::Utc),
|
|
)
|
|
} else {
|
|
// Parse times for timed events
|
|
let start_time = if !request.start_time.is_empty() {
|
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
|
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
|
})?
|
|
} else {
|
|
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
|
};
|
|
|
|
let end_time = if !request.end_time.is_empty() {
|
|
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
|
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
|
})?
|
|
} else {
|
|
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
|
};
|
|
|
|
let start_dt = start_date.and_time(start_time);
|
|
let end_dt = if !request.end_date.is_empty() {
|
|
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
|
.map_err(|_| {
|
|
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
|
})?;
|
|
end_date.and_time(end_time)
|
|
} else {
|
|
start_date.and_time(end_time)
|
|
};
|
|
|
|
// Frontend now sends UTC times, so treat as UTC directly
|
|
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
|
|
(
|
|
start_local.with_timezone(&chrono::Utc),
|
|
end_local.with_timezone(&chrono::Utc),
|
|
)
|
|
};
|
|
|
|
// Generate a unique UID for the series
|
|
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
|
|
|
// Create the VEvent for the series
|
|
let mut event = VEvent::new(uid.clone(), start_datetime);
|
|
event.dtend = Some(end_datetime);
|
|
event.all_day = request.all_day; // Set the all_day flag properly
|
|
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.clone())
|
|
};
|
|
event.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location.clone())
|
|
};
|
|
|
|
// Set event status
|
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
// Set event class
|
|
event.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
// Set priority
|
|
event.priority = request.priority;
|
|
|
|
// 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
|
|
request.recurrence.clone()
|
|
} else {
|
|
// Legacy path: Generate the RRULE for recurrence
|
|
build_series_rrule_with_freq(&request, recurrence_freq)?
|
|
};
|
|
event.rrule = Some(rrule);
|
|
|
|
// 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 series: {}", e)))?;
|
|
|
|
println!(
|
|
"✅ Event series created successfully with UID: {}, href: {}",
|
|
uid, event_href
|
|
);
|
|
|
|
Ok(Json(CreateEventSeriesResponse {
|
|
success: true,
|
|
message: "Event series created successfully".to_string(),
|
|
series_uid: Some(uid),
|
|
occurrences_created: Some(1), // Series created as a single repeating event
|
|
event_href: Some(event_href),
|
|
}))
|
|
}
|
|
|
|
/// Update a recurring event series with different scope options
|
|
pub async fn update_event_series(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<UpdateEventSeriesRequest>,
|
|
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
|
println!(
|
|
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
|
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
|
);
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.series_uid.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Series 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(),
|
|
));
|
|
}
|
|
|
|
// Validate update scope
|
|
match request.update_scope.as_str() {
|
|
"this_only" | "this_and_future" | "all_in_series" => {}
|
|
_ => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid update_scope. Must be: this_only, this_and_future, or all_in_series"
|
|
.to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// Validate recurrence type - handle both simple strings and RRULE strings
|
|
let recurrence_freq = if request.recurrence.contains("FREQ=") {
|
|
// Parse RRULE to extract frequency
|
|
if request.recurrence.contains("FREQ=DAILY") {
|
|
"daily"
|
|
} else if request.recurrence.contains("FREQ=WEEKLY") {
|
|
"weekly"
|
|
} else if request.recurrence.contains("FREQ=MONTHLY") {
|
|
"monthly"
|
|
} else if request.recurrence.contains("FREQ=YEARLY") {
|
|
"yearly"
|
|
} else {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
// Handle simple strings
|
|
let lower = request.recurrence.to_lowercase();
|
|
match lower.as_str() {
|
|
"daily" => "daily",
|
|
"weekly" => "weekly",
|
|
"monthly" => "monthly",
|
|
"yearly" => "yearly",
|
|
_ => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
|
.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);
|
|
|
|
// Use the parsed frequency for further processing (avoiding unused variable warning)
|
|
let _freq_for_processing = recurrence_freq;
|
|
|
|
// Determine which calendar to search (or search all calendars)
|
|
let calendar_paths = if let Some(ref path) = request.calendar_path {
|
|
vec![path.clone()]
|
|
} else {
|
|
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 update".to_string(),
|
|
));
|
|
}
|
|
|
|
// Find the series event across all specified calendars
|
|
let mut existing_event = None;
|
|
let mut calendar_path = String::new();
|
|
|
|
for path in &calendar_paths {
|
|
if let Ok(Some(event)) = client.fetch_event_by_uid(path, &request.series_uid).await {
|
|
existing_event = Some(event);
|
|
calendar_path = path.clone();
|
|
break;
|
|
}
|
|
}
|
|
|
|
let mut existing_event = existing_event.ok_or_else(|| {
|
|
ApiError::NotFound(format!(
|
|
"Event series with UID '{}' not found",
|
|
request.series_uid
|
|
))
|
|
})?;
|
|
|
|
println!("📅 Found series event in calendar: {}", calendar_path);
|
|
println!(
|
|
"📅 Event details: UID={}, summary={:?}, dtstart={}",
|
|
existing_event.uid, existing_event.summary, existing_event.dtstart
|
|
);
|
|
|
|
// Parse datetime components for the update
|
|
let original_start_date = existing_event.dtstart.date_naive();
|
|
|
|
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
|
// For "all_in_series" updates, preserve the original series start date
|
|
let start_date = if (request.update_scope == "this_and_future"
|
|
|| request.update_scope == "this_only")
|
|
&& request.occurrence_date.is_some()
|
|
{
|
|
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
|
|
chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string())
|
|
})?
|
|
} else {
|
|
original_start_date
|
|
};
|
|
|
|
let (start_datetime, end_datetime) = if request.all_day {
|
|
// For all-day events, use noon UTC to avoid timezone boundary issues
|
|
let start_dt = start_date
|
|
.and_hms_opt(12, 0, 0)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
|
|
|
// For all-day events, also preserve the original date pattern
|
|
let end_date = if !request.end_date.is_empty() {
|
|
// Calculate the duration from the original event
|
|
let original_duration_days = existing_event
|
|
.dtend
|
|
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
|
.unwrap_or(0);
|
|
start_date + chrono::Duration::days(original_duration_days)
|
|
} else {
|
|
start_date
|
|
};
|
|
|
|
let end_dt = end_date
|
|
.and_hms_opt(12, 0, 0)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
|
|
// For all-day events, use UTC directly (no local conversion needed)
|
|
(
|
|
chrono::Utc.from_utc_datetime(&start_dt),
|
|
chrono::Utc.from_utc_datetime(&end_dt),
|
|
)
|
|
} else {
|
|
let start_time = if !request.start_time.is_empty() {
|
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
|
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
|
})?
|
|
} else {
|
|
existing_event.dtstart.time()
|
|
};
|
|
|
|
let end_time = if !request.end_time.is_empty() {
|
|
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
|
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
|
})?
|
|
} else {
|
|
existing_event
|
|
.dtend
|
|
.map(|dt| dt.time())
|
|
.unwrap_or_else(|| existing_event.dtstart.time() + chrono::Duration::hours(1))
|
|
};
|
|
|
|
let start_dt = start_date.and_time(start_time);
|
|
let end_dt = if !request.end_time.is_empty() {
|
|
// Use the new end time with the preserved date
|
|
start_date.and_time(end_time)
|
|
} else {
|
|
// Calculate end time based on original duration
|
|
let original_duration = existing_event
|
|
.dtend
|
|
.map(|end| end - existing_event.dtstart)
|
|
.unwrap_or_else(|| chrono::Duration::hours(1));
|
|
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
|
};
|
|
|
|
// Frontend now sends UTC times, so treat as UTC directly
|
|
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
|
|
(
|
|
start_local.with_timezone(&chrono::Utc),
|
|
end_local.with_timezone(&chrono::Utc),
|
|
)
|
|
};
|
|
|
|
// Handle different update scopes
|
|
let (updated_event, occurrences_affected) = match request.update_scope.as_str() {
|
|
"all_in_series" => {
|
|
// Update the entire series - modify the master event
|
|
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
|
|
}
|
|
"this_and_future" => {
|
|
// Split the series: keep past occurrences, create new series from occurrence date
|
|
update_this_and_future(
|
|
&mut existing_event,
|
|
&request,
|
|
start_datetime,
|
|
end_datetime,
|
|
&client,
|
|
&calendar_path,
|
|
)
|
|
.await?
|
|
}
|
|
"this_only" => {
|
|
// Create exception for single occurrence, keep original series
|
|
let event_href = existing_event
|
|
.href
|
|
.as_ref()
|
|
.ok_or_else(|| {
|
|
ApiError::Internal(
|
|
"Event missing href for single occurrence update".to_string(),
|
|
)
|
|
})?
|
|
.clone();
|
|
update_single_occurrence(
|
|
&mut existing_event,
|
|
&request,
|
|
start_datetime,
|
|
end_datetime,
|
|
&client,
|
|
&calendar_path,
|
|
&event_href,
|
|
)
|
|
.await?
|
|
}
|
|
_ => unreachable!(), // Already validated above
|
|
};
|
|
|
|
// Update the event on the CalDAV server using the original event's href
|
|
println!("📤 Updating event on CalDAV server...");
|
|
let event_href = existing_event
|
|
.href
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
|
println!("📤 Using event href: {}", event_href);
|
|
println!("📤 Calendar path: {}", calendar_path);
|
|
|
|
match client
|
|
.update_event(&calendar_path, &updated_event, event_href)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
println!("✅ CalDAV update completed successfully");
|
|
}
|
|
Err(e) => {
|
|
println!("❌ CalDAV update failed: {}", e);
|
|
return Err(ApiError::Internal(format!(
|
|
"Failed to update event series: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
|
|
println!(
|
|
"✅ Event series updated successfully with UID: {}",
|
|
request.series_uid
|
|
);
|
|
|
|
Ok(Json(UpdateEventSeriesResponse {
|
|
success: true,
|
|
message: "Event series updated successfully".to_string(),
|
|
series_uid: Some(request.series_uid),
|
|
occurrences_affected: Some(occurrences_affected),
|
|
}))
|
|
}
|
|
|
|
/// Delete a recurring event series or specific occurrences
|
|
pub async fn delete_event_series(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<DeleteEventSeriesRequest>,
|
|
) -> Result<Json<DeleteEventSeriesResponse>, ApiError> {
|
|
println!(
|
|
"🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",
|
|
request.series_uid, request.delete_scope
|
|
);
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.series_uid.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Series UID is required".to_string()));
|
|
}
|
|
|
|
if request.calendar_path.trim().is_empty() {
|
|
return Err(ApiError::BadRequest(
|
|
"Calendar path is required".to_string(),
|
|
));
|
|
}
|
|
|
|
if request.event_href.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Event href is required".to_string()));
|
|
}
|
|
|
|
// Validate delete scope
|
|
match request.delete_scope.as_str() {
|
|
"this_only" | "this_and_future" | "all_in_series" => {}
|
|
_ => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series"
|
|
.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);
|
|
|
|
// Handle different deletion scopes
|
|
let occurrences_affected = match request.delete_scope.as_str() {
|
|
"all_in_series" => {
|
|
// Delete the entire series - simply delete the event
|
|
delete_entire_series(&client, &request).await?
|
|
}
|
|
"this_and_future" => {
|
|
// Modify RRULE to end before this occurrence
|
|
delete_this_and_future(&client, &request).await?
|
|
}
|
|
"this_only" => {
|
|
// Add EXDATE for single occurrence
|
|
delete_single_occurrence(&client, &request).await?
|
|
}
|
|
_ => unreachable!(), // Already validated above
|
|
};
|
|
|
|
println!(
|
|
"✅ Event series deletion completed with {} occurrences affected",
|
|
occurrences_affected
|
|
);
|
|
|
|
Ok(Json(DeleteEventSeriesResponse {
|
|
success: true,
|
|
message: "Event series deletion completed successfully".to_string(),
|
|
occurrences_affected: Some(occurrences_affected),
|
|
}))
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
fn build_series_rrule_with_freq(
|
|
request: &CreateEventSeriesRequest,
|
|
freq: &str,
|
|
) -> Result<String, ApiError> {
|
|
let mut rrule_parts = Vec::new();
|
|
|
|
// Add frequency
|
|
match freq {
|
|
"daily" => rrule_parts.push("FREQ=DAILY".to_string()),
|
|
"weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()),
|
|
"monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()),
|
|
"yearly" => rrule_parts.push("FREQ=YEARLY".to_string()),
|
|
_ => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid recurrence frequency".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// Add interval if specified and greater than 1
|
|
if let Some(interval) = request.recurrence_interval {
|
|
if interval > 1 {
|
|
rrule_parts.push(format!("INTERVAL={}", interval));
|
|
}
|
|
}
|
|
|
|
// Handle weekly recurrence with specific days (BYDAY)
|
|
if freq == "weekly" && 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_parts.push(format!("BYDAY={}", selected_days.join(",")));
|
|
}
|
|
}
|
|
|
|
// Add end date if specified (UNTIL takes precedence over COUNT)
|
|
if let Some(end_date) = &request.recurrence_end_date {
|
|
// Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ)
|
|
match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
|
Ok(date) => {
|
|
let end_datetime = date
|
|
.and_hms_opt(23, 59, 59)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
let utc_end = chrono::Utc.from_utc_datetime(&end_datetime);
|
|
rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ")));
|
|
}
|
|
Err(_) => {
|
|
return Err(ApiError::BadRequest(
|
|
"Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string(),
|
|
))
|
|
}
|
|
}
|
|
} else if let Some(count) = request.recurrence_count {
|
|
if count > 0 {
|
|
rrule_parts.push(format!("COUNT={}", count));
|
|
}
|
|
}
|
|
|
|
Ok(rrule_parts.join(";"))
|
|
}
|
|
|
|
/// Update the entire series - modify the master event
|
|
fn update_entire_series(
|
|
existing_event: &mut VEvent,
|
|
request: &UpdateEventSeriesRequest,
|
|
start_datetime: chrono::DateTime<chrono::Utc>,
|
|
end_datetime: chrono::DateTime<chrono::Utc>,
|
|
) -> Result<(VEvent, u32), ApiError> {
|
|
// Clone the existing event to preserve all metadata
|
|
let mut updated_event = existing_event.clone();
|
|
|
|
// Update only the modified properties from the request
|
|
updated_event.dtstart = start_datetime;
|
|
updated_event.dtend = Some(end_datetime);
|
|
updated_event.summary = if request.title.trim().is_empty() {
|
|
existing_event.summary.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.title.clone())
|
|
};
|
|
updated_event.description = if request.description.trim().is_empty() {
|
|
existing_event.description.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.description.clone())
|
|
};
|
|
updated_event.location = if request.location.trim().is_empty() {
|
|
existing_event.location.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.location.clone())
|
|
};
|
|
|
|
updated_event.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
updated_event.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
updated_event.priority = request.priority;
|
|
|
|
// Update timestamps
|
|
let now = chrono::Utc::now();
|
|
updated_event.dtstamp = now;
|
|
updated_event.last_modified = Some(now);
|
|
// Keep original created timestamp to preserve event history
|
|
|
|
// Update RRULE if recurrence parameters are provided
|
|
if let Some(ref existing_rrule) = updated_event.rrule {
|
|
let mut new_rrule = existing_rrule.clone();
|
|
println!("🔄 Original RRULE: {}", existing_rrule);
|
|
|
|
// Update COUNT if provided
|
|
if let Some(count) = request.recurrence_count {
|
|
println!("🔄 Updating RRULE with new COUNT: {}", count);
|
|
// Remove old COUNT or UNTIL parameters
|
|
new_rrule = new_rrule.split(';')
|
|
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
|
|
.collect::<Vec<_>>()
|
|
.join(";");
|
|
// Add new COUNT
|
|
new_rrule = format!("{};COUNT={}", new_rrule, count);
|
|
} else if let Some(ref end_date) = request.recurrence_end_date {
|
|
println!("🔄 Updating RRULE with new UNTIL: {}", end_date);
|
|
// Remove old COUNT or UNTIL parameters
|
|
new_rrule = new_rrule.split(';')
|
|
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
|
|
.collect::<Vec<_>>()
|
|
.join(";");
|
|
// Add new UNTIL (convert YYYY-MM-DD to YYYYMMDD format)
|
|
let until_date = end_date.replace("-", "");
|
|
new_rrule = format!("{};UNTIL={}", new_rrule, until_date);
|
|
}
|
|
|
|
println!("🔄 Updated RRULE: {}", new_rrule);
|
|
updated_event.rrule = Some(new_rrule);
|
|
}
|
|
|
|
// Copy the updated event back to existing_event for the main handler
|
|
*existing_event = updated_event.clone();
|
|
|
|
Ok((updated_event, 1)) // 1 series updated (affects all occurrences)
|
|
}
|
|
|
|
/// Update this occurrence and all future occurrences (RFC 5545 compliant series splitting)
|
|
///
|
|
/// This function implements the "this and future events" modification pattern for recurring
|
|
/// event series by splitting the original series into two parts:
|
|
///
|
|
/// ## Operation Overview:
|
|
/// 1. **Terminate Original Series**: Adds an UNTIL clause to the original recurring event
|
|
/// to stop generating occurrences before the target occurrence date.
|
|
/// 2. **Create New Series**: Creates a completely new recurring series starting from the
|
|
/// target occurrence date with the updated properties (new times, title, etc.).
|
|
///
|
|
/// ## Example Scenario:
|
|
/// - Original series: "Daily meeting 9:00-10:00 AM" (Aug 15 onwards, no end date)
|
|
/// - User drags Aug 22 occurrence to 2:00-3:00 PM
|
|
/// - Result:
|
|
/// - Original series: "Daily meeting 9:00-10:00 AM" with UNTIL=Aug 22 midnight (covers Aug 15-21)
|
|
/// - New series: "Daily meeting 2:00-3:00 PM" starting Aug 22 (covers Aug 22 onwards)
|
|
///
|
|
/// ## RFC 5545 Compliance:
|
|
/// - Uses UNTIL property in RRULE to cleanly terminate the original series
|
|
/// - Preserves original event UIDs and CalDAV metadata
|
|
/// - Maintains proper DTSTAMP and LAST-MODIFIED timestamps
|
|
/// - New series gets fresh UID to avoid conflicts
|
|
///
|
|
/// ## CalDAV Operations:
|
|
/// This function performs two sequential CalDAV operations:
|
|
/// 1. CREATE new series on the CalDAV server
|
|
/// 2. UPDATE original series (handled by caller) with UNTIL clause
|
|
///
|
|
/// Operations are serialized using a global mutex to prevent race conditions.
|
|
///
|
|
/// ## Parameters:
|
|
/// - `existing_event`: The original recurring event to be split
|
|
/// - `request`: Update request containing new properties and occurrence_date
|
|
/// - `start_datetime`/`end_datetime`: New times for the future occurrences
|
|
/// - `client`: CalDAV client for server operations
|
|
/// - `calendar_path`: CalDAV calendar path where events are stored
|
|
///
|
|
/// ## Returns:
|
|
/// - `(VEvent, u32)`: Updated original event with UNTIL clause, and count of operations (2)
|
|
///
|
|
/// ## Error Handling:
|
|
/// - Validates occurrence_date format and presence
|
|
/// - Handles CalDAV server communication errors
|
|
/// - Ensures atomic operations (both succeed or both fail)
|
|
async fn update_this_and_future(
|
|
existing_event: &mut VEvent,
|
|
request: &UpdateEventSeriesRequest,
|
|
start_datetime: chrono::DateTime<chrono::Utc>,
|
|
end_datetime: chrono::DateTime<chrono::Utc>,
|
|
client: &CalDAVClient,
|
|
calendar_path: &str,
|
|
) -> Result<(VEvent, u32), ApiError> {
|
|
// Clone the existing event to create the new series before modifying the RRULE of the
|
|
// original, because we'd like to preserve the original UNTIL logic
|
|
let mut new_series = existing_event.clone();
|
|
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
|
ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string())
|
|
})?;
|
|
|
|
// Parse occurrence date
|
|
let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
|
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?;
|
|
|
|
// Step 1: Add UNTIL to the original series to stop before the occurrence date
|
|
let until_datetime = occurrence_date_parsed
|
|
.and_hms_opt(0, 0, 0)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
|
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
|
|
|
// Create modified RRULE with UNTIL clause for the original series
|
|
let original_rrule = existing_event
|
|
.rrule
|
|
.clone()
|
|
.unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
|
let parts: Vec<&str> = original_rrule
|
|
.split(';')
|
|
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
|
.collect();
|
|
|
|
existing_event.rrule = Some(format!(
|
|
"{};UNTIL={}",
|
|
parts.join(";"),
|
|
utc_until.format("%Y%m%dT%H%M%SZ")
|
|
));
|
|
println!(
|
|
"🔄 this_and_future: Updated original series RRULE: {:?}",
|
|
existing_event.rrule
|
|
);
|
|
|
|
// Step 2: Create a new series starting from the occurrence date with updated properties
|
|
let new_series_uid = format!("series-{}", uuid::Uuid::new_v4());
|
|
|
|
// Update the new series with new properties
|
|
new_series.uid = new_series_uid.clone();
|
|
new_series.dtstart = start_datetime;
|
|
new_series.dtend = Some(end_datetime);
|
|
new_series.summary = if request.title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.title.clone())
|
|
};
|
|
new_series.description = if request.description.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.description.clone())
|
|
};
|
|
new_series.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location.clone())
|
|
};
|
|
|
|
new_series.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
new_series.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
new_series.priority = request.priority;
|
|
|
|
// Update timestamps
|
|
let now = chrono::Utc::now();
|
|
new_series.dtstamp = now;
|
|
new_series.created = Some(now);
|
|
new_series.last_modified = Some(now);
|
|
new_series.href = None; // Will be set when created
|
|
|
|
println!(
|
|
"🔄 this_and_future: Creating new series with UID: {}",
|
|
new_series_uid
|
|
);
|
|
println!(
|
|
"🔄 this_and_future: New series RRULE: {:?}",
|
|
new_series.rrule
|
|
);
|
|
|
|
// Create the new series on CalDAV server
|
|
client
|
|
.create_event(calendar_path, &new_series)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?;
|
|
|
|
println!("✅ this_and_future: Created new series successfully");
|
|
|
|
// Return the original event (with UNTIL added) - it will be updated by the main handler
|
|
Ok((existing_event.clone(), 2)) // 2 operations: updated original + created new series
|
|
}
|
|
|
|
/// Update only a single occurrence (create an exception)
|
|
async fn update_single_occurrence(
|
|
existing_event: &mut VEvent,
|
|
request: &UpdateEventSeriesRequest,
|
|
start_datetime: chrono::DateTime<chrono::Utc>,
|
|
end_datetime: chrono::DateTime<chrono::Utc>,
|
|
client: &CalDAVClient,
|
|
calendar_path: &str,
|
|
_original_event_href: &str,
|
|
) -> Result<(VEvent, u32), ApiError> {
|
|
// For RFC 5545 compliant single occurrence updates, we need to:
|
|
// 1. Add EXDATE to the original series to exclude this occurrence
|
|
// 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence
|
|
|
|
// First, add EXDATE to the original series
|
|
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
|
ApiError::BadRequest(
|
|
"occurrence_date is required for single occurrence updates".to_string(),
|
|
)
|
|
})?;
|
|
|
|
// Parse the occurrence date
|
|
let exception_date =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
|
})?;
|
|
|
|
// Create the EXDATE datetime using the original event's time
|
|
let original_time = existing_event.dtstart.time();
|
|
let exception_datetime = exception_date.and_time(original_time);
|
|
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
|
|
// Add the exception date to the original series
|
|
println!(
|
|
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
|
existing_event.exdate
|
|
);
|
|
existing_event.exdate.push(exception_utc);
|
|
println!(
|
|
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
|
existing_event.exdate
|
|
);
|
|
println!(
|
|
"🚫 Added EXDATE for single occurrence modification: {}",
|
|
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
|
);
|
|
|
|
// Create exception event by cloning the existing event to preserve all metadata
|
|
let mut exception_event = existing_event.clone();
|
|
|
|
// Give the exception event a unique UID (required for CalDAV)
|
|
exception_event.uid = format!("exception-{}", uuid::Uuid::new_v4());
|
|
|
|
// Update the modified properties from the request
|
|
exception_event.dtstart = start_datetime;
|
|
exception_event.dtend = Some(end_datetime);
|
|
exception_event.summary = if request.title.trim().is_empty() {
|
|
existing_event.summary.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.title.clone())
|
|
};
|
|
exception_event.description = if request.description.trim().is_empty() {
|
|
existing_event.description.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.description.clone())
|
|
};
|
|
exception_event.location = if request.location.trim().is_empty() {
|
|
existing_event.location.clone() // Keep original if empty
|
|
} else {
|
|
Some(request.location.clone())
|
|
};
|
|
|
|
exception_event.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
exception_event.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
exception_event.priority = request.priority;
|
|
|
|
// Update timestamps for the exception event
|
|
let now = chrono::Utc::now();
|
|
exception_event.dtstamp = now;
|
|
exception_event.last_modified = Some(now);
|
|
// Keep original created timestamp to preserve event history
|
|
|
|
// Set RECURRENCE-ID to point to the original occurrence
|
|
// exception_event.recurrence_id = Some(exception_utc);
|
|
|
|
// Remove any recurrence rules from the exception (it's a single event)
|
|
exception_event.rrule = None;
|
|
exception_event.rdate.clear();
|
|
exception_event.exdate.clear();
|
|
|
|
// Set calendar path for the exception event
|
|
exception_event.calendar_path = Some(calendar_path.to_string());
|
|
|
|
println!(
|
|
"✨ Created exception event with RECURRENCE-ID: {}",
|
|
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
|
);
|
|
|
|
// Create the exception event as a new event (original series will be updated by main handler)
|
|
client
|
|
.create_event(calendar_path, &exception_event)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?;
|
|
|
|
println!("✅ Created exception event successfully");
|
|
|
|
// Return the original series (now with EXDATE) - main handler will update it on CalDAV
|
|
Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception)
|
|
}
|
|
|
|
/// Delete the entire series
|
|
async fn delete_entire_series(
|
|
client: &CalDAVClient,
|
|
request: &DeleteEventSeriesRequest,
|
|
) -> Result<u32, ApiError> {
|
|
// Simply delete the entire event from the CalDAV server
|
|
client
|
|
.delete_event(&request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?;
|
|
|
|
println!("🗑️ Entire series deleted: {}", request.series_uid);
|
|
Ok(1) // 1 series deleted (affects all occurrences)
|
|
}
|
|
|
|
/// Delete this occurrence and all future occurrences (modify RRULE with UNTIL)
|
|
async fn delete_this_and_future(
|
|
client: &CalDAVClient,
|
|
request: &DeleteEventSeriesRequest,
|
|
) -> Result<u32, ApiError> {
|
|
// Fetch the existing event to modify its RRULE
|
|
let event_uid = request.series_uid.clone();
|
|
let existing_event = client
|
|
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
|
.ok_or_else(|| {
|
|
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
|
})?;
|
|
|
|
// If no occurrence_date is provided, delete the entire series
|
|
let Some(occurrence_date) = &request.occurrence_date else {
|
|
return delete_entire_series(client, request).await;
|
|
};
|
|
|
|
// Parse occurrence date to set as UNTIL for the RRULE
|
|
let until_date =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
|
})?;
|
|
|
|
// Set UNTIL to the day before the occurrence to exclude it and all future occurrences
|
|
let until_datetime = until_date
|
|
.pred_opt()
|
|
.ok_or_else(|| {
|
|
ApiError::BadRequest("Cannot delete from the first possible date".to_string())
|
|
})?
|
|
.and_hms_opt(23, 59, 59)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?;
|
|
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
|
|
|
// Modify the existing event's RRULE
|
|
let mut updated_event = existing_event;
|
|
if let Some(rrule) = &updated_event.rrule {
|
|
// Remove existing UNTIL or COUNT if present and add new UNTIL
|
|
let parts: Vec<&str> = rrule
|
|
.split(';')
|
|
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
|
.collect();
|
|
|
|
updated_event.rrule = Some(format!(
|
|
"{};UNTIL={}",
|
|
parts.join(";"),
|
|
utc_until.format("%Y%m%dT%H%M%SZ")
|
|
));
|
|
}
|
|
|
|
// Update the event on the CalDAV server
|
|
client
|
|
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!("Failed to update event series for deletion: {}", e))
|
|
})?;
|
|
|
|
println!(
|
|
"🗑️ Series modified with UNTIL for this_and_future deletion: {}",
|
|
utc_until.format("%Y-%m-%d")
|
|
);
|
|
Ok(1) // 1 series modified
|
|
}
|
|
|
|
/// Delete only a single occurrence (add EXDATE)
|
|
async fn delete_single_occurrence(
|
|
client: &CalDAVClient,
|
|
request: &DeleteEventSeriesRequest,
|
|
) -> Result<u32, ApiError> {
|
|
// Fetch the existing event to add EXDATE
|
|
let event_uid = request.series_uid.clone();
|
|
let existing_event = client
|
|
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
|
.ok_or_else(|| {
|
|
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
|
})?;
|
|
|
|
// If no occurrence_date is provided, cannot delete single occurrence
|
|
let Some(occurrence_date) = &request.occurrence_date else {
|
|
return Err(ApiError::BadRequest(
|
|
"occurrence_date is required for single occurrence deletion".to_string(),
|
|
));
|
|
};
|
|
|
|
// Parse occurrence date
|
|
let exception_date =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
|
})?;
|
|
|
|
// Create the EXDATE datetime (use the same time as the original event)
|
|
let original_time = existing_event.dtstart.time();
|
|
let exception_datetime = exception_date.and_time(original_time);
|
|
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
|
|
// Add the exception date to the event's EXDATE list
|
|
let mut updated_event = existing_event;
|
|
updated_event.exdate.push(exception_utc);
|
|
|
|
println!(
|
|
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
|
exception_utc.format("%Y%m%dT%H%M%SZ")
|
|
);
|
|
|
|
// Update the event on the CalDAV server
|
|
client
|
|
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!(
|
|
"Failed to update event series for single deletion: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
Ok(1) // 1 occurrence excluded
|
|
}
|