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:
694
backend/src/handlers/series.rs
Normal file
694
backend/src/handlers/series.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user