fix(528): pass3: PUT with RECURRENCE-ID

This commit is contained in:
Edouard Vanbelle
2026-07-14 17:46:24 +02:00
parent 02f67f7a5c
commit 7966c7178a
11 changed files with 805 additions and 79 deletions
@@ -13,7 +13,7 @@ use crate::application::dtos::calendar_dto::{
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
UpdateCalendarDto, UpdateEventDto,
};
use crate::application::ports::calendar_ports::CalendarStoragePort;
use crate::application::ports::calendar_ports::{CalendarStoragePort, UpsertEventsResult};
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::calendar::Calendar;
use crate::domain::entities::calendar_event::CalendarEvent;
@@ -262,6 +262,73 @@ impl CalendarStoragePort for CalendarStorageAdapter {
Ok(CalendarEventDto::from(created))
}
async fn upsert_ical_events(
&self,
dto: CreateEventICalDto,
) -> Result<UpsertEventsResult, DomainError> {
let calendar_id = Uuid::parse_str(&dto.calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Event",
"Invalid calendar ID format",
)
})?;
// Verify calendar exists before touching the events table.
let _calendar = self
.calendar_repository
.find_calendar_by_id(&calendar_id)
.await?;
// Split the body into one CalendarEvent per VEVENT. A body
// with zero VEVENTs (or only VTODOs / VJOURNALs) returns
// InvalidInput here — which the handler layer maps to 400.
let parsed = CalendarEvent::parse_all_events(calendar_id, &dto.ical_data)?;
let mut out = Vec::with_capacity(parsed.len());
let mut any_inserted = false;
for event in parsed {
let ical_uid = event.ical_uid().to_string();
// Existing row lookup routes on the master/exception split.
// Master: (calendar_id, ical_uid) WHERE recurrence_id IS NULL
// Exception: (calendar_id, ical_uid, recurrence_id)
let existing = match event.recurrence_id().copied() {
Some(rid) => {
self.event_repository
.find_event_by_ical_uid_and_recurrence_id(&calendar_id, &ical_uid, &rid)
.await?
}
None => {
self.event_repository
.find_event_by_ical_uid(&calendar_id, &ical_uid)
.await?
}
};
// Delete-then-insert keeps the DB-level partial unique
// indexes happy and matches the pre-#528 update semantics
// of the single-event path (fresh row id per replace,
// ETag changes on update).
if let Some(existing_event) = existing {
self.event_repository
.delete_event(existing_event.id())
.await?;
} else {
any_inserted = true;
}
let created = self.event_repository.create_event(event).await?;
out.push(CalendarEventDto::from(created));
}
Ok(UpsertEventsResult {
events: out,
any_inserted,
})
}
async fn update_event(
&self,
event_id: &str,
@@ -389,6 +389,66 @@ impl CalendarEventRepository for CalendarEventPgRepository {
}
}
async fn find_event_by_ical_uid_and_recurrence_id(
&self,
calendar_id: &Uuid,
ical_uid: &str,
recurrence_id: &DateTime<Utc>,
) -> CalendarEventRepositoryResult<Option<CalendarEvent>> {
// Uses idx_calendar_events_exception_unique — the partial
// unique index on (calendar_id, ical_uid, recurrence_id)
// WHERE recurrence_id IS NOT NULL — for the exact-match seek.
let row_opt = sqlx::query(
r#"
SELECT
id, calendar_id, summary, description, location,
start_time, end_time, all_day, rrule,
created_at, updated_at, ical_uid, ical_data, recurrence_id
FROM caldav.calendar_events
WHERE calendar_id = $1
AND ical_uid = $2
AND recurrence_id = $3
"#,
)
.bind(calendar_id)
.bind(ical_uid)
.bind(recurrence_id)
.fetch_optional(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!(
"Failed to get calendar event exception by UID+RECURRENCE-ID: {}",
e
))
})?;
match row_opt {
Some(row) => {
let mut event = CalendarEvent::with_id(
row.get("id"),
row.get("calendar_id"),
row.get("summary"),
row.get::<Option<String>, _>("description"),
row.get::<Option<String>, _>("location"),
row.get("start_time"),
row.get("end_time"),
row.get("all_day"),
row.get::<Option<String>, _>("rrule"),
row.get("ical_uid"),
row.get("ical_data"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Error creating calendar event: {}", e))
})?;
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
Ok(Some(event))
}
None => Ok(None),
}
}
async fn find_events_by_ical_uids(
&self,
calendar_id: &Uuid,