Implement complete event series endpoints with full CRUD support

## Backend Implementation
- Add dedicated series endpoints: create, update, delete
- Implement RFC 5545 compliant RRULE generation and modification
- Support all scope operations: this_only, this_and_future, all_in_series
- Add comprehensive series-specific request/response models
- Implement EXDATE and RRULE modification for precise occurrence control

## Frontend Integration
- Add automatic series detection and smart endpoint routing
- Implement scope-aware event operations with backward compatibility
- Enhance API payloads with series-specific fields
- Integrate existing RecurringEditModal for scope selection UI

## Testing
- Add comprehensive integration tests for all series endpoints
- Validate scope handling, RRULE generation, and error scenarios
- All 14 integration tests passing

🤖 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:21:44 -04:00
parent b195208ddc
commit e21430f6ff
6 changed files with 1344 additions and 58 deletions

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, RecurringEditModal, RecurringEditAction};
use crate::services::{CalendarService, calendar_service::UserInfo};
use crate::models::ical::VEvent;
use chrono::NaiveDate;
@@ -54,6 +54,9 @@ pub fn App() -> Html {
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
let recurring_edit_modal_open = use_state(|| false);
let recurring_edit_event = use_state(|| -> Option<VEvent> { None });
let recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
// Calendar view state - load from localStorage if available
let current_view = use_state(|| {

View File

@@ -602,6 +602,26 @@ impl CalendarService {
event_href: String,
delete_action: String,
occurrence_date: Option<String>
) -> Result<String, String> {
// Forward to delete_event_with_uid with extracted UID
let event_uid = event_href.trim_end_matches(".ics").to_string();
self.delete_event_with_uid(
token, password, calendar_path, event_href, delete_action,
occurrence_date, event_uid, None // No recurrence info available
).await
}
/// Delete an event from the CalDAV server with UID and recurrence support
pub async fn delete_event_with_uid(
&self,
token: &str,
password: &str,
calendar_path: String,
event_href: String,
delete_action: String,
occurrence_date: Option<String>,
event_uid: String,
recurrence: Option<String>
) -> Result<String, String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -609,17 +629,44 @@ impl CalendarService {
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"calendar_path": calendar_path,
"event_href": event_href,
"delete_action": delete_action,
"occurrence_date": occurrence_date
});
// Determine if this is a series event based on recurrence
let is_series = recurrence.as_ref()
.map(|r| !r.is_empty() && r.to_uppercase() != "NONE")
.unwrap_or(false);
let (body, url) = if is_series {
// Use series-specific endpoint and payload for recurring events
// Map delete_action to delete_scope for series endpoint
let delete_scope = match delete_action.as_str() {
"delete_this" => "this_only",
"delete_following" => "this_and_future",
"delete_series" => "all_in_series",
_ => "this_only" // Default to single occurrence
};
let body = serde_json::json!({
"series_uid": event_uid,
"calendar_path": calendar_path,
"event_href": event_href,
"delete_scope": delete_scope,
"occurrence_date": occurrence_date
});
let url = format!("{}/calendar/events/series/delete", self.base_url);
(body, url)
} else {
// Use regular endpoint for non-recurring events
let body = serde_json::json!({
"calendar_path": calendar_path,
"event_href": event_href,
"delete_action": delete_action,
"occurrence_date": occurrence_date
});
let url = format!("{}/calendar/events/delete", self.base_url);
(body, url)
};
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let url = format!("{}/calendar/events/delete", self.base_url);
opts.set_body(&body_string.into());
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
@@ -689,31 +736,64 @@ impl CalendarService {
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
});
// Determine if this is a series event based on recurrence
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
let (body, url) = if is_series {
// Use series-specific endpoint and payload for recurring events
let body = serde_json::json!({
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"calendar_path": calendar_path
});
let url = format!("{}/calendar/events/series/create", self.base_url);
(body, url)
} else {
// Use regular endpoint for non-recurring events
let body = serde_json::json!({
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
});
let url = format!("{}/calendar/events/create", self.base_url);
(body, url)
};
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let url = format!("{}/calendar/events/create", self.base_url);
opts.set_body(&body_string.into());
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
@@ -775,6 +855,45 @@ impl CalendarService {
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>
) -> Result<(), String> {
// Forward to update_event_with_scope with default scope
self.update_event_with_scope(
token, password, event_uid, title, description,
start_date, start_time, end_date, end_time, location,
all_day, status, class, priority, organizer, attendees,
categories, reminder, recurrence, recurrence_days,
calendar_path, exception_dates, update_action, until_date,
"all_in_series".to_string() // Default scope for backward compatibility
).await
}
pub async fn update_event_with_scope(
&self,
token: &str,
password: &str,
event_uid: String,
title: String,
description: String,
start_date: String,
start_time: String,
end_date: String,
end_time: String,
location: String,
all_day: bool,
status: String,
class: String,
priority: Option<u8>,
organizer: String,
attendees: String,
categories: String,
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
update_scope: String
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -782,36 +901,72 @@ impl CalendarService {
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"uid": event_uid,
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path,
"update_action": update_action,
"occurrence_date": null,
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
});
// Determine if this is a series event based on recurrence
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
let (body, url) = if is_series {
// Use series-specific endpoint and payload for recurring events
let body = serde_json::json!({
"series_uid": event_uid,
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%d").to_string()),
"recurrence_count": None as Option<u32>, // No count limit by default
"calendar_path": calendar_path,
"update_scope": update_scope,
"occurrence_date": None as Option<String> // For specific occurrence updates
});
let url = format!("{}/calendar/events/series/update", self.base_url);
(body, url)
} else {
// Use regular endpoint for non-recurring events
let body = serde_json::json!({
"uid": event_uid,
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path,
"update_action": update_action,
"occurrence_date": null,
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
});
let url = format!("{}/calendar/events/update", self.base_url);
(body, url)
};
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let url = format!("{}/calendar/events/update", self.base_url);
opts.set_body(&body_string.into());
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;