fix(528): pass3: PUT with RECURRENCE-ID
This commit is contained in:
@@ -35,6 +35,7 @@ mod tests {
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: "uid-evt-001@oxicloud".to_string(),
|
||||
recurrence_id: None,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ pub struct CalendarEventDto {
|
||||
pub all_day: bool,
|
||||
pub rrule: Option<String>,
|
||||
pub ical_uid: String,
|
||||
/// RFC 5545 §3.8.4.4 RECURRENCE-ID. `None` on masters and on
|
||||
/// non-recurring events; `Some` on per-instance exception
|
||||
/// overrides. Two rows sharing (`calendar_id`, `ical_uid`) but
|
||||
/// distinguished by this field represent a recurring master and
|
||||
/// its modified occurrence(s) respectively (see #528).
|
||||
pub recurrence_id: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -106,6 +112,7 @@ impl Default for CalendarEventDto {
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: String::new(),
|
||||
recurrence_id: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
@@ -125,6 +132,7 @@ impl From<CalendarEvent> for CalendarEventDto {
|
||||
all_day: event.all_day(),
|
||||
rrule: event.rrule().map(|s| s.to_string()),
|
||||
ical_uid: event.ical_uid().to_string(),
|
||||
recurrence_id: event.recurrence_id().copied(),
|
||||
created_at: *event.created_at(),
|
||||
updated_at: *event.updated_at(),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,19 @@ use crate::common::errors::DomainError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Result of a multi-VEVENT PUT (`upsert_ical_events`). See #528.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpsertEventsResult {
|
||||
/// Every event that was persisted for this PUT. Ordered as they
|
||||
/// appeared in the body — the master (if present) is typically
|
||||
/// first, followed by exception overrides.
|
||||
pub events: Vec<CalendarEventDto>,
|
||||
/// True if at least one row was newly created; false if every
|
||||
/// event replaced an existing row. Drives the handler's choice
|
||||
/// between 201 Created and 204 No Content.
|
||||
pub any_inserted: bool,
|
||||
}
|
||||
|
||||
/// Port for external calendar storage mechanisms
|
||||
pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
// Calendar operations
|
||||
@@ -53,6 +66,23 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
/// Upsert every VEVENT in an iCalendar body — one master and zero
|
||||
/// or more per-instance exception overrides (RFC 5545 §3.8.4.4).
|
||||
///
|
||||
/// Routing: an event whose `RECURRENCE-ID` is unset targets the
|
||||
/// master row `(calendar_id, ical_uid) WHERE recurrence_id IS NULL`;
|
||||
/// an event whose `RECURRENCE-ID` is set targets its own exception
|
||||
/// row `(calendar_id, ical_uid, recurrence_id)` and never touches
|
||||
/// the master. Existing rows are replaced (delete-then-insert to
|
||||
/// stay compatible with the DB-level partial unique indexes and to
|
||||
/// keep the ETag surface identical to the pre-#528 single-event
|
||||
/// path).
|
||||
///
|
||||
/// See AtalayaLabs/OxiCloud#528.
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<UpsertEventsResult, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
@@ -136,6 +166,15 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
/// Route a PUT'd iCalendar body containing one or more VEVENTs to
|
||||
/// their per-instance rows. See `CalendarStoragePort::upsert_ical_events`
|
||||
/// for the routing rules; this method just adds the `Permission::Create`
|
||||
/// gate for the caller.
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<UpsertEventsResult, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::application::dtos::calendar_dto::{
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::application::ports::calendar_ports::{
|
||||
CalendarStoragePort, CalendarUseCase, UpsertEventsResult,
|
||||
};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||
@@ -234,6 +236,21 @@ impl CalendarUseCase for CalendarService {
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<UpsertEventsResult, DomainError> {
|
||||
// Same gate as create_event_from_ical — a PUT to the collection
|
||||
// is a write. `Permission::Create` matches the single-event
|
||||
// path; per-instance exception updates ride on the same
|
||||
// permission because from the ACL's perspective it's still
|
||||
// a write to the calendar.
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
self.calendar_storage.upsert_ical_events(event).await
|
||||
}
|
||||
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user