Files
calendar/backend/src/handlers/series.rs
Connor Johnstone 089f4ce105 Fix series RRULE updates: editing 'all events' now properly updates original series RRULE
- 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>
2025-09-03 17:22:26 -04:00

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
}