- Backend now updates RRULE when recurrence_count or recurrence_end_date parameters are provided - Fixed update_entire_series() to modify COUNT/UNTIL instead of preserving original RRULE - Added comprehensive RRULE parsing functions to extract existing frequency, interval, count, until, and BYDAY components - Fixed frontend parameter mapping to pass recurrence parameters through update_series calls - Resolves issue where changing recurring event from 5 to 7 occurrences kept original COUNT=5 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1217 lines
44 KiB
Rust
1217 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()))?;
|
|
|
|
// Convert from local time to UTC
|
|
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
|
|
(
|
|
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)
|
|
};
|
|
|
|
// Convert from local time to UTC
|
|
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
|
|
(
|
|
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 {
|
|
let start_dt = start_date
|
|
.and_hms_opt(0, 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(23, 59, 59)
|
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
|
|
// Convert from local time to UTC
|
|
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
|
|
(
|
|
start_local.with_timezone(&chrono::Utc),
|
|
end_local.with_timezone(&chrono::Utc),
|
|
)
|
|
} 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()
|
|
};
|
|
|
|
// Convert from local time to UTC
|
|
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
|
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
|
.single()
|
|
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
|
|
|
(
|
|
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
|
|
}
|