Fix recurring event series modification via drag and drop operations

This commit resolves the "Failed to fetch" errors when updating recurring
event series through drag operations by implementing proper request
sequencing and fixing time parameter handling.

Key fixes:
- Eliminate HTTP request cancellation by sequencing operations properly
- Add global mutex to prevent CalDAV HTTP race conditions
- Implement complete RFC 5545-compliant series splitting for "this_and_future"
- Fix frontend to pass dragged times instead of original times
- Add comprehensive error handling and request timing logs
- Backend now handles both UPDATE (add UNTIL) and CREATE (new series) in single request

Technical changes:
- Frontend: Remove concurrent CREATE request, pass dragged times to backend
- Backend: Implement full this_and_future logic with sequential operations
- CalDAV: Add mutex serialization and detailed error tracking
- Series: Create new series with occurrence date + dragged times

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-30 20:17:36 -04:00
parent 1794cf9a59
commit 783e13eb10
6 changed files with 315 additions and 204 deletions

View File

@@ -32,6 +32,7 @@ regex = "1.0"
dotenvy = "0.15"
base64 = "0.21"
thiserror = "1.0"
lazy_static = "1.4"
[dev-dependencies]
tokio = { version = "1.0", features = ["macros", "rt"] }

View File

@@ -1,8 +1,16 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
use std::sync::Arc;
use tokio::sync::Mutex;
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm};
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
lazy_static::lazy_static! {
static ref CALDAV_HTTP_MUTEX: Arc<Mutex<()>> = Arc::new(Mutex::new(()));
}
/// Type alias for shared VEvent (for backward compatibility during migration)
pub type CalendarEvent = VEvent;
@@ -105,9 +113,15 @@ pub struct CalDAVClient {
impl CalDAVClient {
/// Create a new CalDAV client with the given configuration
pub fn new(config: crate::config::CalDAVConfig) -> Self {
// Create HTTP client with global timeout to prevent hanging requests
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60)) // 60 second global timeout
.build()
.expect("Failed to create HTTP client");
Self {
config,
http_client: reqwest::Client::new(),
http_client,
}
}
@@ -773,6 +787,10 @@ impl CalDAVClient {
println!("Creating event at: {}", full_url);
println!("iCal data: {}", ical_data);
println!("📡 Acquiring CalDAV HTTP lock for CREATE request...");
let _lock = CALDAV_HTTP_MUTEX.lock().await;
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
let response = self.http_client
.put(&full_url)
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
@@ -823,15 +841,49 @@ impl CalDAVClient {
println!("📝 Updated iCal data: {}", ical_data);
println!("📝 Event has {} exception dates", event.exdate.len());
let response = self.http_client
println!("📡 Acquiring CalDAV HTTP lock for PUT request...");
let _lock = CALDAV_HTTP_MUTEX.lock().await;
println!("📡 Lock acquired, sending PUT request to CalDAV server...");
println!("🔗 PUT URL: {}", full_url);
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
let request_builder = self.http_client
.put(&full_url)
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
.header("Content-Type", "text/calendar; charset=utf-8")
.header("User-Agent", "calendar-app/0.1.0")
.body(ical_data)
.send()
.await
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
.timeout(std::time::Duration::from_secs(30))
.body(ical_data);
println!("📡 About to execute PUT request at {}", chrono::Utc::now().format("%H:%M:%S%.3f"));
let start_time = std::time::Instant::now();
let response_result = request_builder.send().await;
let elapsed = start_time.elapsed();
println!("📡 PUT request completed after {}ms at {}", elapsed.as_millis(), chrono::Utc::now().format("%H:%M:%S%.3f"));
let response = response_result.map_err(|e| {
println!("❌ HTTP PUT request failed after {}ms: {}", elapsed.as_millis(), e);
println!("❌ Error source: {:?}", e.source());
println!("❌ Error string: {}", e.to_string());
if e.is_timeout() {
println!("❌ Error was a timeout");
} else if e.is_connect() {
println!("❌ Error was a connection error");
} else if e.is_request() {
println!("❌ Error was a request error");
} else if e.to_string().contains("operation was canceled") || e.to_string().contains("cancelled") {
println!("❌ Error indicates operation was cancelled");
} else {
println!("❌ Error was of unknown type");
}
// Check if this might be a concurrent request issue
if e.to_string().contains("cancel") {
println!("⚠️ Potential race condition detected - request was cancelled, possibly by another concurrent operation");
}
CalDAVError::ParseError(e.to_string())
})?;
println!("Event update response status: {}", response.status());
@@ -1020,6 +1072,10 @@ impl CalDAVClient {
println!("Deleting event at: {}", full_url);
println!("📡 Acquiring CalDAV HTTP lock for DELETE request...");
let _lock = CALDAV_HTTP_MUTEX.lock().await;
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
let response = self.http_client
.delete(&full_url)
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))

View File

@@ -242,19 +242,39 @@ pub async fn update_event_series(
};
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
println!("🔄 Creating CalDAV config for series update...");
let config = match state.auth_service.caldav_config_from_token(&token, &password) {
Ok(config) => {
println!("✅ CalDAV config created successfully");
config
}
Err(e) => {
println!("❌ Failed to create CalDAV config: {}", e);
return Err(e);
}
};
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)
println!("🔍 Determining calendar paths...");
let calendar_paths = if let Some(ref path) = request.calendar_path {
println!("✅ Using specified calendar path: {}", path);
vec![path.clone()]
} else {
client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
println!("🔍 Discovering all available calendars...");
match client.discover_calendars().await {
Ok(paths) => {
println!("✅ Discovered {} calendar paths", paths.len());
paths
}
Err(e) => {
println!("❌ Failed to discover calendars: {}", e);
return Err(ApiError::Internal(format!("Failed to discover calendars: {}", e)));
}
}
};
if calendar_paths.is_empty() {
@@ -262,14 +282,25 @@ pub async fn update_event_series(
}
// Find the series event across all specified calendars
println!("🔍 Searching for series UID '{}' across {} calendar(s)...", request.series_uid, calendar_paths.len());
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;
println!("🔍 Searching calendar path: {}", path);
match client.fetch_event_by_uid(path, &request.series_uid).await {
Ok(Some(event)) => {
println!("✅ Found series event in calendar: {}", path);
existing_event = Some(event);
calendar_path = path.clone();
break;
}
Ok(None) => {
println!("❌ Series event not found in calendar: {}", path);
}
Err(e) => {
println!("❌ Error searching calendar {}: {}", path, e);
}
}
}
@@ -277,60 +308,88 @@ pub async fn update_event_series(
.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
// For recurring events, preserve the original series start date and only update the time
// to prevent the entire series from shifting to a different date
println!("🕒 Parsing datetime components...");
let original_start_date = existing_event.dtstart.date_naive();
let start_date = original_start_date; // Always use original series date
// For "this_and_future" updates, use the occurrence date for the new series
// For other updates, preserve the original series start date
let start_date = if request.update_scope == "this_and_future" && request.occurrence_date.is_some() {
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
let occurrence_date = 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()))?;
println!("🕒 Using occurrence date: {} for this_and_future update", occurrence_date);
occurrence_date
} else {
println!("🕒 Using original start date: {} for series update", original_start_date);
original_start_date
};
// Log what we're doing for debugging
println!("🕒 Parsing requested start date: {}", request.start_date);
let requested_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()))?;
println!("📅 Preserving original series date {} (requested: {})", original_start_date, requested_date);
println!("🕒 Determining datetime format (all_day: {})...", request.all_day);
let (start_datetime, end_datetime) = if request.all_day {
println!("🕒 Processing all-day event...");
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() {
println!("🕒 Calculating end date from original duration...");
// 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);
println!("🕒 Original duration: {} days", original_duration_days);
start_date + chrono::Duration::days(original_duration_days)
} else {
println!("🕒 Using same date for end date");
start_date
};
let end_dt = end_date.and_hms_opt(23, 59, 59)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
println!("🕒 All-day datetime range: {} to {}", start_dt, end_dt);
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
} else {
println!("🕒 Processing timed event...");
let start_time = if !request.start_time.is_empty() {
println!("🕒 Parsing start time: {}", request.start_time);
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
} else {
println!("🕒 Using existing event start time");
existing_event.dtstart.time()
};
let end_time = if !request.end_time.is_empty() {
println!("🕒 Parsing end time: {}", request.end_time);
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
} else {
println!("🕒 Calculating end time from existing event");
existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| {
existing_event.dtstart.time() + chrono::Duration::hours(1)
})
};
println!("🕒 Calculated times: start={}, end={}", start_time, end_time);
let start_dt = start_date.and_time(start_time);
// For timed events, preserve the original date and only update times
let end_dt = if !request.end_time.is_empty() {
println!("🕒 Using new end time with preserved date");
// Use the new end time with the preserved original date
start_date.and_time(end_time)
} else {
println!("🕒 Calculating end time based on original duration");
// Calculate end time based on original duration
let original_duration = existing_event.dtend
.map(|end| end - existing_event.dtstart)
@@ -338,37 +397,57 @@ pub async fn update_event_series(
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
};
println!("🕒 Timed datetime range: {} to {}", start_dt, end_dt);
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
};
// Handle different update scopes
println!("🎯 Handling update scope: '{}'", request.update_scope);
let (updated_event, occurrences_affected) = match request.update_scope.as_str() {
"all_in_series" => {
println!("🎯 Processing all_in_series update...");
// Update the entire series - modify the master event
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
},
"this_and_future" => {
println!("🎯 Processing this_and_future update...");
// Split the series: keep past occurrences, create new series from occurrence date
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime)?
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await?
},
"this_only" => {
println!("🎯 Processing this_only update...");
// 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();
println!("🎯 Using event href: {}", event_href);
update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await?
},
_ => unreachable!(), // Already validated above
};
println!("✅ Update scope processing completed, {} occurrences affected", occurrences_affected);
// Update the event on the CalDAV server using the original event's href
// Note: For "this_only" updates, the original series was already updated in update_single_occurrence
if request.update_scope != "this_only" {
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()))?;
client.update_event(&calendar_path, &updated_event, event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to update event series: {}", e)))?;
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)));
}
}
} else {
println!("📤 Skipping CalDAV update (already handled in this_only scope)");
}
println!("✅ Event series updated successfully with UID: {}", request.series_uid);
@@ -602,43 +681,92 @@ fn update_entire_series(
}
/// Update this occurrence and all future occurrences
fn update_this_and_future(
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> {
// For now, treat this the same as update_entire_series
// In a full implementation, this would:
// Full implementation:
// 1. Add UNTIL to the original series to stop at the occurrence date
// 2. Create a new series starting from the occurrence date with updated properties
// For simplicity, we'll modify the original series with an UNTIL date if occurrence_date is provided
if let Some(occurrence_date) = &request.occurrence_date {
// Parse occurrence date and set as UNTIL for the original series
match chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
Ok(date) => {
let until_datetime = date.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
let mut rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string());
// Remove existing UNTIL or COUNT if present
let parts: Vec<&str> = rrule.split(';').filter(|part| {
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
}).collect();
rrule = format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"));
existing_event.rrule = Some(rrule);
},
Err(_) => return Err(ApiError::BadRequest("Invalid occurrence date format".to_string())),
}
}
println!("🔄 this_and_future: occurrence_date = {:?}", request.occurrence_date);
// Then apply the same updates as all_in_series for the rest of the properties
update_entire_series(existing_event, request, start_datetime, end_datetime)
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());
let mut new_series = existing_event.clone();
// 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;
// Reset the RRULE for the new series (remove UNTIL)
let new_rrule_parts: Vec<&str> = original_rrule.split(';').filter(|part| {
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
}).collect();
new_series.rrule = Some(new_rrule_parts.join(";"));
// 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)