Refactor handlers.rs into modular structure for better maintainability

- Split 1921-line handlers.rs into focused modules:
  - handlers/auth.rs: Authentication handlers (login, verify_token, get_user_info)
  - handlers/calendar.rs: Calendar management (create_calendar, delete_calendar)
  - handlers/events.rs: Event operations (CRUD operations, fetch events)
  - handlers/series.rs: Event series operations (recurring events management)
- Main handlers.rs now serves as clean re-export module
- All tests passing (14 integration + 7 unit + 3 doc tests)
- Maintains backward compatibility with existing API routes
- Improves code organization and separation of concerns

🤖 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 13:35:13 -04:00
parent e21430f6ff
commit 78f1db7203
6 changed files with 1515 additions and 1923 deletions

View File

@@ -0,0 +1,694 @@
use axum::{
extract::State,
http::HeaderMap,
response::Json,
};
use std::sync::Arc;
use chrono::TimeZone;
use crate::{AppState, models::{ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, DeleteEventSeriesRequest, DeleteEventSeriesResponse}};
use crate::calendar::CalDAVClient;
use calendar_models::{VEvent, EventStatus, EventClass};
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
match request.recurrence.to_lowercase().as_str() {
"daily" | "weekly" | "monthly" | "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()))?;
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
} 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)
};
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
};
// 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.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;
// Generate the RRULE for recurrence
let rrule = build_series_rrule(&request)?;
event.rrule = Some(rrule);
println!("🔁 Generated RRULE: {:?}", event.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='{}'",
request.series_uid, request.update_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.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
match request.recurrence.to_lowercase().as_str() {
"daily" | "weekly" | "monthly" | "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 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);
// Parse datetime components for the update
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 {
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()))?;
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
} else {
let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
} else {
existing_event.dtstart.time()
};
let end_time = if !request.end_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
} else {
existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| {
existing_event.dtstart.time() + chrono::Duration::hours(1)
})
};
let start_dt = start_date.and_time(start_time);
let end_dt = if !request.end_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)
};
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
};
// 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)?
},
"this_only" => {
// Create exception for single occurrence, keep original series
update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime)?
},
_ => unreachable!(), // Already validated above
};
// Update the event on the CalDAV server
// Generate event href from UID
let event_href = format!("{}.ics", request.series_uid);
client.update_event(&calendar_path, &updated_event, &event_href)
.await
.map_err(|e| 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(request: &CreateEventSeriesRequest) -> Result<String, ApiError> {
let mut rrule_parts = Vec::new();
// Add frequency
match request.recurrence.to_lowercase().as_str() {
"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 type".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 request.recurrence.to_lowercase() == "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> {
// Create a new series request for RRULE generation
let series_request = CreateEventSeriesRequest {
title: request.title.clone(),
description: request.description.clone(),
start_date: request.start_date.clone(),
start_time: request.start_time.clone(),
end_date: request.end_date.clone(),
end_time: request.end_time.clone(),
location: request.location.clone(),
all_day: request.all_day,
status: request.status.clone(),
class: request.class.clone(),
priority: request.priority,
organizer: request.organizer.clone(),
attendees: request.attendees.clone(),
categories: request.categories.clone(),
reminder: request.reminder.clone(),
recurrence: request.recurrence.clone(),
recurrence_days: request.recurrence_days.clone(),
recurrence_interval: request.recurrence_interval,
recurrence_end_date: request.recurrence_end_date.clone(),
recurrence_count: request.recurrence_count,
calendar_path: None, // Not needed for RRULE generation
};
// Update all fields of the existing event
existing_event.dtstart = start_datetime;
existing_event.dtend = Some(end_datetime);
existing_event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
existing_event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
existing_event.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
existing_event.status = Some(match request.status.to_lowercase().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
});
existing_event.class = Some(match request.class.to_lowercase().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
});
existing_event.priority = request.priority;
// Update the RRULE
existing_event.rrule = Some(build_series_rrule(&series_request)?);
Ok((existing_event.clone(), 1)) // 1 series updated (affects all occurrences)
}
/// Update this occurrence and all future occurrences
fn update_this_and_future(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
) -> Result<(VEvent, u32), ApiError> {
// For now, treat this the same as update_entire_series
// In a full implementation, this would:
// 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())),
}
}
// 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)
}
/// Update only a single occurrence (create an exception)
fn update_single_occurrence(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
) -> Result<(VEvent, u32), ApiError> {
// For single occurrence updates, we need to:
// 1. Keep the original series unchanged
// 2. Create a new single event (exception) with the same UID but different RECURRENCE-ID
// Create a new event for the single occurrence
let occurrence_uid = if let Some(occurrence_date) = &request.occurrence_date {
format!("{}-exception-{}", existing_event.uid, occurrence_date)
} else {
format!("{}-exception", existing_event.uid)
};
let mut exception_event = VEvent::new(occurrence_uid, start_datetime);
exception_event.dtend = Some(end_datetime);
exception_event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
exception_event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
exception_event.location = if request.location.trim().is_empty() { None } 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;
// Note: This function returns the exception event, but in a full implementation,
// we would need to create this as a separate event and add an EXDATE to the original series
Ok((exception_event, 1)) // 1 occurrence updated
}
/// 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
}