fix(528): pass2 add recurrence_id field
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- caldav.calendar_events — add RECURRENCE-ID column for exception instances
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Motivation: AtalayaLabs/OxiCloud#528 — CalDAV clients (Thunderbird, Apple
|
||||||
|
-- Calendar, Gnome Calendar, DAVx⁵) modify a single occurrence of a recurring
|
||||||
|
-- event by PUTting a separate VEVENT that shares the master's UID and adds
|
||||||
|
-- a RECURRENCE-ID identifying which occurrence is overridden (RFC 5545
|
||||||
|
-- §3.8.4.4).
|
||||||
|
--
|
||||||
|
-- Pre-#528 behaviour: modifications either hit a UID collision (silent
|
||||||
|
-- 500 or corrupt state) or overwrote the master. Post-#528 the exception
|
||||||
|
-- override lives as its own row keyed by
|
||||||
|
-- (calendar_id, ical_uid, recurrence_id), with the master identified by
|
||||||
|
-- `recurrence_id IS NULL`.
|
||||||
|
--
|
||||||
|
-- Related but distinct from parser Phase 1 (rewrite of extract_ical_property
|
||||||
|
-- on top of the `ical` crate) — that landed in the same branch to enable
|
||||||
|
-- parsing RECURRENCE-ID at all. This migration is the storage half.
|
||||||
|
--
|
||||||
|
-- No backfill needed — pre-migration events all become masters (NULL). No
|
||||||
|
-- existing exception rows existed because the parser couldn't read them.
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Column: nullable. NULL = master, non-NULL = exception instance whose
|
||||||
|
-- value pinpoints which occurrence of the recurring master is being
|
||||||
|
-- overridden. TIMESTAMPTZ so both timed (DATE-TIME) and all-day (DATE)
|
||||||
|
-- RECURRENCE-IDs fit — the domain-side `parse_ical_datetime` normalises
|
||||||
|
-- both into `DateTime<Utc>` (all-day → midnight UTC of the target date).
|
||||||
|
ALTER TABLE caldav.calendar_events
|
||||||
|
ADD COLUMN recurrence_id TIMESTAMP WITH TIME ZONE NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN caldav.calendar_events.recurrence_id IS
|
||||||
|
'RFC 5545 §3.8.4.4 RECURRENCE-ID. NULL on the master, non-NULL on '
|
||||||
|
'per-instance exception overrides. Keyed with (calendar_id, ical_uid) '
|
||||||
|
'via the two partial unique indexes below.';
|
||||||
|
|
||||||
|
-- Partial unique index: at most one master row per (calendar_id, ical_uid).
|
||||||
|
--
|
||||||
|
-- Without this a client that re-uses a UID across calendar events (e.g. a
|
||||||
|
-- pre-2026-08 import that didn't dedupe) could produce two masters — the
|
||||||
|
-- lookup by (calendar_id, ical_uid) WHERE recurrence_id IS NULL would then
|
||||||
|
-- be ambiguous and the exception-routing logic would either overwrite the
|
||||||
|
-- wrong master or refuse to insert. Pre-migration duplicates would fail
|
||||||
|
-- this index creation; if that happens, the reconciliation is out of scope
|
||||||
|
-- for this migration (dedup script would go here — but the existing
|
||||||
|
-- codebase generates fresh UIDs on ambiguity so it shouldn't fire in
|
||||||
|
-- practice).
|
||||||
|
CREATE UNIQUE INDEX idx_calendar_events_master_unique
|
||||||
|
ON caldav.calendar_events (calendar_id, ical_uid)
|
||||||
|
WHERE recurrence_id IS NULL;
|
||||||
|
|
||||||
|
-- Partial unique index: at most one exception override per
|
||||||
|
-- (calendar_id, ical_uid, recurrence_id). Prevents two rows both claiming
|
||||||
|
-- to override the same instance of the same master — which would confuse
|
||||||
|
-- the client on next PROPFIND.
|
||||||
|
CREATE UNIQUE INDEX idx_calendar_events_exception_unique
|
||||||
|
ON caldav.calendar_events (calendar_id, ical_uid, recurrence_id)
|
||||||
|
WHERE recurrence_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Read-path index for the "give me the master + all its exceptions"
|
||||||
|
-- query the PROPFIND handler will run. Covered by the two unique indexes
|
||||||
|
-- above only partially — this covering index reads the full
|
||||||
|
-- (calendar_id, ical_uid) pair in one seek regardless of which side of
|
||||||
|
-- the master/exception split.
|
||||||
|
CREATE INDEX idx_calendar_events_uid_lookup
|
||||||
|
ON caldav.calendar_events (calendar_id, ical_uid);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -51,6 +51,29 @@ pub struct CalendarEvent {
|
|||||||
/// Recurrence rule in iCalendar RRULE format (optional)
|
/// Recurrence rule in iCalendar RRULE format (optional)
|
||||||
rrule: Option<String>,
|
rrule: Option<String>,
|
||||||
|
|
||||||
|
/// RECURRENCE-ID (RFC 5545 §3.8.4.4) — non-NULL on exception
|
||||||
|
/// instances of a recurring event, NULL on the master.
|
||||||
|
///
|
||||||
|
/// When a client (Thunderbird, Apple Calendar, Gnome Calendar, …)
|
||||||
|
/// modifies a SINGLE occurrence of a recurring event, it sends
|
||||||
|
/// a separate VEVENT that shares the master's UID and carries
|
||||||
|
/// a `RECURRENCE-ID` identifying which occurrence is being
|
||||||
|
/// overridden. That per-instance override lives as its own row
|
||||||
|
/// in `caldav.calendar_events`; the master row keeps NULL here.
|
||||||
|
///
|
||||||
|
/// Lookup key is `(calendar_id, ical_uid, recurrence_id)` —
|
||||||
|
/// enforced at the DB layer by two partial unique indexes:
|
||||||
|
///
|
||||||
|
/// * `(calendar_id, ical_uid) WHERE recurrence_id IS NULL` —
|
||||||
|
/// at most one master per UID per calendar.
|
||||||
|
/// * `(calendar_id, ical_uid, recurrence_id) WHERE
|
||||||
|
/// recurrence_id IS NOT NULL` — at most one override for a
|
||||||
|
/// given (master, instance) pair.
|
||||||
|
///
|
||||||
|
/// See AtalayaLabs/OxiCloud#528 for the ticket that motivated
|
||||||
|
/// this field, and `docs/plan/` (future) for the full model.
|
||||||
|
recurrence_id: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
/// Unique identifier in iCalendar format (used for CalDAV sync)
|
/// Unique identifier in iCalendar format (used for CalDAV sync)
|
||||||
ical_uid: String,
|
ical_uid: String,
|
||||||
|
|
||||||
@@ -140,6 +163,7 @@ impl CalendarEvent {
|
|||||||
end_time,
|
end_time,
|
||||||
all_day,
|
all_day,
|
||||||
rrule,
|
rrule,
|
||||||
|
recurrence_id: None,
|
||||||
ical_uid: Uuid::new_v4().to_string(),
|
ical_uid: Uuid::new_v4().to_string(),
|
||||||
ical_data,
|
ical_data,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -209,6 +233,7 @@ impl CalendarEvent {
|
|||||||
end_time,
|
end_time,
|
||||||
all_day,
|
all_day,
|
||||||
rrule,
|
rrule,
|
||||||
|
recurrence_id: None,
|
||||||
ical_uid,
|
ical_uid,
|
||||||
ical_data,
|
ical_data,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -268,10 +293,7 @@ impl CalendarEvent {
|
|||||||
// and anything else means timed.
|
// and anything else means timed.
|
||||||
let all_day = dtstart_params
|
let all_day = dtstart_params
|
||||||
.get("VALUE")
|
.get("VALUE")
|
||||||
.map(|vs| {
|
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||||
vs.iter()
|
|
||||||
.any(|v| v.eq_ignore_ascii_case("DATE"))
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let start_time = Self::parse_ical_datetime(&dtstart_value, all_day).map_err(|e| {
|
let start_time = Self::parse_ical_datetime(&dtstart_value, all_day).map_err(|e| {
|
||||||
@@ -299,6 +321,26 @@ impl CalendarEvent {
|
|||||||
let ical_uid = Self::extract_ical_property(&ical_data, "UID")
|
let ical_uid = Self::extract_ical_property(&ical_data, "UID")
|
||||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
|
||||||
|
// RECURRENCE-ID (RFC 5545 §3.8.4.4). When present, this VEVENT
|
||||||
|
// is an override for a specific occurrence of a recurring
|
||||||
|
// master with the same UID. The parameter tells us whether the
|
||||||
|
// value is a date (all-day master) or datetime (timed master).
|
||||||
|
// A parse failure here downgrades to `None` — the VEVENT still
|
||||||
|
// gets stored, just as a plain event (worst case a client sync
|
||||||
|
// treats it as a new master, which the DB uniqueness will
|
||||||
|
// refuse; better a persistence error than a silent split).
|
||||||
|
let recurrence_id =
|
||||||
|
match Self::extract_ical_property_with_params(&ical_data, "RECURRENCE-ID") {
|
||||||
|
Some((value, params)) => {
|
||||||
|
let is_date = params
|
||||||
|
.get("VALUE")
|
||||||
|
.map(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("DATE")))
|
||||||
|
.unwrap_or(false);
|
||||||
|
Self::parse_ical_datetime(&value, is_date).ok()
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -311,6 +353,7 @@ impl CalendarEvent {
|
|||||||
end_time,
|
end_time,
|
||||||
all_day,
|
all_day,
|
||||||
rrule,
|
rrule,
|
||||||
|
recurrence_id,
|
||||||
ical_uid,
|
ical_uid,
|
||||||
ical_data,
|
ical_data,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -366,6 +409,24 @@ impl CalendarEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the event's iCalendar UID
|
/// Returns the event's iCalendar UID
|
||||||
|
/// Returns the RECURRENCE-ID for this event, if any. `None` on
|
||||||
|
/// masters and standalone (non-recurring) events; `Some` on
|
||||||
|
/// exception overrides that target a specific occurrence of a
|
||||||
|
/// recurring master with the same `ical_uid`.
|
||||||
|
pub fn recurrence_id(&self) -> Option<&DateTime<Utc>> {
|
||||||
|
self.recurrence_id.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the RECURRENCE-ID on this event. Used by the repository
|
||||||
|
/// layer when reconstructing an entity from a stored row (the
|
||||||
|
/// column is read straight into the field — no re-parse of the
|
||||||
|
/// ical_data body). Passing `None` clears the marker, promoting
|
||||||
|
/// an exception back to a plain event.
|
||||||
|
pub fn set_recurrence_id(&mut self, recurrence_id: Option<DateTime<Utc>>) {
|
||||||
|
self.recurrence_id = recurrence_id;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ical_uid(&self) -> &str {
|
pub fn ical_uid(&self) -> &str {
|
||||||
&self.ical_uid
|
&self.ical_uid
|
||||||
}
|
}
|
||||||
@@ -591,8 +652,7 @@ impl CalendarEvent {
|
|||||||
self.start_time = start_time;
|
self.start_time = start_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((value, _params)) =
|
if let Some((value, _params)) = Self::extract_ical_property_with_params(&ical_data, "DTEND")
|
||||||
Self::extract_ical_property_with_params(&ical_data, "DTEND")
|
|
||||||
&& let Ok(end_time) = Self::parse_ical_datetime(&value, all_day)
|
&& let Ok(end_time) = Self::parse_ical_datetime(&value, all_day)
|
||||||
{
|
{
|
||||||
self.end_time = end_time;
|
self.end_time = end_time;
|
||||||
@@ -1059,7 +1119,7 @@ END:VCALENDAR\r
|
|||||||
let ev = parse_ok(RECURRING_WITH_EXCEPTION);
|
let ev = parse_ok(RECURRING_WITH_EXCEPTION);
|
||||||
assert_eq!(ev.summary(), "Daily standup");
|
assert_eq!(ev.summary(), "Daily standup");
|
||||||
assert_eq!(ev.ical_uid(), "daily-1@oxicloud.test");
|
assert_eq!(ev.ical_uid(), "daily-1@oxicloud.test");
|
||||||
assert_eq!(ev.rrule().as_deref(), Some("FREQ=DAILY;COUNT=10"));
|
assert_eq!(ev.rrule(), Some("FREQ=DAILY;COUNT=10"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1121,4 +1181,115 @@ END:VCALENDAR\r
|
|||||||
let v = CalendarEvent::extract_ical_property(TIMED_EVENT, "dtstart");
|
let v = CalendarEvent::extract_ical_property(TIMED_EVENT, "dtstart");
|
||||||
assert_eq!(v.as_deref(), Some("20260101T120000Z"));
|
assert_eq!(v.as_deref(), Some("20260101T120000Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Phase 2 — RECURRENCE-ID extraction into the entity
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_event_has_no_recurrence_id() {
|
||||||
|
// A plain VEVENT (no RECURRENCE-ID line) should carry a NULL
|
||||||
|
// recurrence_id — that's what marks it as a master in the DB.
|
||||||
|
let ev = parse_ok(TIMED_EVENT);
|
||||||
|
assert!(
|
||||||
|
ev.recurrence_id().is_none(),
|
||||||
|
"master should have recurrence_id = None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recurring_master_has_no_recurrence_id_even_with_rrule() {
|
||||||
|
// The presence of RRULE on the master does not by itself
|
||||||
|
// populate recurrence_id — only RECURRENCE-ID does. The
|
||||||
|
// exception-instance VEVENT in the same VCALENDAR carries
|
||||||
|
// RECURRENCE-ID; `parse_first_vevent` returns the master, so
|
||||||
|
// we get `None` here. Phase 3 will introduce a `parse_all_events`
|
||||||
|
// helper to surface the exceptions.
|
||||||
|
let ev = parse_ok(RECURRING_WITH_EXCEPTION);
|
||||||
|
assert!(
|
||||||
|
ev.recurrence_id().is_none(),
|
||||||
|
"master with RRULE should still have recurrence_id = None"
|
||||||
|
);
|
||||||
|
assert_eq!(ev.rrule().as_deref(), Some("FREQ=DAILY;COUNT=10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timed_exception_populates_recurrence_id() {
|
||||||
|
// A standalone exception-override VEVENT (as sent by a client
|
||||||
|
// that's already synced the master and is now modifying one
|
||||||
|
// instance) parses with recurrence_id = the RECURRENCE-ID's
|
||||||
|
// timestamp. This is the phase-2 half of #528 — the value is
|
||||||
|
// preserved through the domain model; phase 3 will use it to
|
||||||
|
// route inserts to their own row.
|
||||||
|
let exception = "\
|
||||||
|
BEGIN:VCALENDAR\r
|
||||||
|
VERSION:2.0\r
|
||||||
|
PRODID:-//OxiCloud test//EN\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:daily-1@oxicloud.test\r
|
||||||
|
DTSTAMP:20260101T100000Z\r
|
||||||
|
DTSTART:20260103T110000Z\r
|
||||||
|
DTEND:20260103T120000Z\r
|
||||||
|
SUMMARY:Daily standup — rescheduled\r
|
||||||
|
RECURRENCE-ID:20260103T090000Z\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR\r
|
||||||
|
";
|
||||||
|
let ev = parse_ok(exception);
|
||||||
|
let rid = ev
|
||||||
|
.recurrence_id()
|
||||||
|
.expect("exception must have recurrence_id set");
|
||||||
|
assert_eq!(
|
||||||
|
rid.to_rfc3339(),
|
||||||
|
"2026-01-03T09:00:00+00:00",
|
||||||
|
"RECURRENCE-ID must parse to the timed override timestamp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_day_exception_populates_recurrence_id_at_midnight() {
|
||||||
|
// RECURRENCE-ID;VALUE=DATE:20260112 — the exact shape #528
|
||||||
|
// flagged. Domain normalises the DATE form to midnight UTC on
|
||||||
|
// the given day so the field's type stays `DateTime<Utc>`.
|
||||||
|
let exception = "\
|
||||||
|
BEGIN:VCALENDAR\r
|
||||||
|
VERSION:2.0\r
|
||||||
|
PRODID:-//OxiCloud test//EN\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:weekly-allday@oxicloud.test\r
|
||||||
|
DTSTAMP:20260101T100000Z\r
|
||||||
|
DTSTART;VALUE=DATE:20260113\r
|
||||||
|
DTEND;VALUE=DATE:20260114\r
|
||||||
|
SUMMARY:Weekly all-day — rescheduled\r
|
||||||
|
RECURRENCE-ID;VALUE=DATE:20260112\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR\r
|
||||||
|
";
|
||||||
|
let ev = parse_ok(exception);
|
||||||
|
let rid = ev
|
||||||
|
.recurrence_id()
|
||||||
|
.expect("all-day exception must have recurrence_id set");
|
||||||
|
assert_eq!(
|
||||||
|
rid.to_rfc3339(),
|
||||||
|
"2026-01-12T00:00:00+00:00",
|
||||||
|
"all-day RECURRENCE-ID must normalise to 00:00:00 UTC of the target date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_recurrence_id_setter_round_trips() {
|
||||||
|
// Repository rehydration path: `with_id` initialises
|
||||||
|
// recurrence_id to None; the repo calls `set_recurrence_id`
|
||||||
|
// with the DB column value. Prove both branches survive the
|
||||||
|
// setter cleanly.
|
||||||
|
let mut ev = parse_ok(TIMED_EVENT);
|
||||||
|
assert!(ev.recurrence_id().is_none());
|
||||||
|
|
||||||
|
let target = Utc.with_ymd_and_hms(2026, 3, 15, 12, 0, 0).unwrap();
|
||||||
|
ev.set_recurrence_id(Some(target));
|
||||||
|
assert_eq!(ev.recurrence_id(), Some(&target));
|
||||||
|
|
||||||
|
ev.set_recurrence_id(None);
|
||||||
|
assert!(ev.recurrence_id().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO caldav.calendar_events (
|
INSERT INTO caldav.calendar_events (
|
||||||
id, calendar_id, summary, description, location, start_time, end_time,
|
id, calendar_id, summary, description, location, start_time, end_time,
|
||||||
all_day, rrule, created_at, updated_at, ical_uid, ical_data
|
all_day, rrule, created_at, updated_at, ical_uid, ical_data,
|
||||||
|
recurrence_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(event.id())
|
.bind(event.id())
|
||||||
@@ -49,6 +50,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.bind(event.updated_at())
|
.bind(event.updated_at())
|
||||||
.bind(event.ical_uid())
|
.bind(event.ical_uid())
|
||||||
.bind(event.ical_data())
|
.bind(event.ical_data())
|
||||||
|
// NULL on masters, non-NULL on exception overrides — see the
|
||||||
|
// `20260913000001_calendar_events_recurrence_id.sql` migration
|
||||||
|
// and `docs/architecture/rebac-authorization.md` follow-up doc.
|
||||||
|
.bind(event.recurrence_id().copied())
|
||||||
.execute(&*self.pool)
|
.execute(&*self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -68,16 +73,17 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE caldav.calendar_events
|
UPDATE caldav.calendar_events
|
||||||
SET summary = $1,
|
SET summary = $1,
|
||||||
description = $2,
|
description = $2,
|
||||||
location = $3,
|
location = $3,
|
||||||
start_time = $4,
|
start_time = $4,
|
||||||
end_time = $5,
|
end_time = $5,
|
||||||
all_day = $6,
|
all_day = $6,
|
||||||
rrule = $7,
|
rrule = $7,
|
||||||
ical_data = $8,
|
ical_data = $8,
|
||||||
updated_at = $9
|
recurrence_id = $9,
|
||||||
WHERE id = $10
|
updated_at = $10
|
||||||
|
WHERE id = $11
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(event.summary())
|
.bind(event.summary())
|
||||||
@@ -88,6 +94,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.bind(event.all_day())
|
.bind(event.all_day())
|
||||||
.bind(event.rrule())
|
.bind(event.rrule())
|
||||||
.bind(event.ical_data())
|
.bind(event.ical_data())
|
||||||
|
.bind(event.recurrence_id().copied())
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(event.id())
|
.bind(event.id())
|
||||||
.execute(&*self.pool)
|
.execute(&*self.pool)
|
||||||
@@ -126,12 +133,12 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE calendar_id = $1
|
WHERE calendar_id = $1
|
||||||
AND (
|
AND (
|
||||||
(start_time >= $2 AND start_time < $3) OR
|
(start_time >= $2 AND start_time < $3) OR
|
||||||
(end_time > $2 AND end_time <= $3) OR
|
(end_time > $2 AND end_time <= $3) OR
|
||||||
@@ -152,7 +159,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let event = CalendarEvent::with_id(
|
let mut event = CalendarEvent::with_id(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -170,6 +177,11 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
// Rehydrate the RECURRENCE-ID after entity construction —
|
||||||
|
// `with_id` initialises to `None` because the field predates
|
||||||
|
// the rest of the constructor signature (#528). Keeping
|
||||||
|
// `with_id` unchanged avoids ripple-changing every caller.
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +191,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent> {
|
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -195,11 +207,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
})?
|
})?
|
||||||
.ok_or_else(|| DomainError::not_found("Calendar Event", id.to_string()))?;
|
.ok_or_else(|| DomainError::not_found("Calendar Event", id.to_string()))?;
|
||||||
|
|
||||||
// In a real implementation, we would build a complete CalendarEvent object
|
let mut event = CalendarEvent::with_id(
|
||||||
// For simplicity, we create an object with default values to
|
|
||||||
// demonstrate the approach without macros
|
|
||||||
|
|
||||||
let event = CalendarEvent::with_id(
|
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -217,6 +225,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
|
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
@@ -227,10 +236,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE calendar_id = $1
|
WHERE calendar_id = $1
|
||||||
ORDER BY start_time
|
ORDER BY start_time
|
||||||
@@ -245,7 +254,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let event = CalendarEvent::with_id(
|
let mut event = CalendarEvent::with_id(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -263,6 +272,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,10 +288,10 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE calendar_id = $1 AND summary ILIKE $2
|
WHERE calendar_id = $1 AND summary ILIKE $2
|
||||||
ORDER BY start_time
|
ORDER BY start_time
|
||||||
@@ -297,7 +307,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let event = CalendarEvent::with_id(
|
let mut event = CalendarEvent::with_id(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -315,6 +325,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,14 +337,21 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
calendar_id: &Uuid,
|
calendar_id: &Uuid,
|
||||||
ical_uid: &str,
|
ical_uid: &str,
|
||||||
) -> CalendarEventRepositoryResult<Option<CalendarEvent>> {
|
) -> CalendarEventRepositoryResult<Option<CalendarEvent>> {
|
||||||
|
// Phase 2 note: this method looks up "an event with this UID"
|
||||||
|
// — the SELECT still isn't filtered on `recurrence_id IS NULL`
|
||||||
|
// because the phase-3 handler routing (which will distinguish
|
||||||
|
// master vs. exception override at PUT time) is where the
|
||||||
|
// filter actually needs to live. For phase 2 the invariant is
|
||||||
|
// enforced only at INSERT time via the two partial unique
|
||||||
|
// indexes; reads see whatever's there.
|
||||||
let row_opt = sqlx::query(
|
let row_opt = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE calendar_id = $1 AND ical_uid = $2
|
WHERE calendar_id = $1 AND ical_uid = $2 AND recurrence_id IS NULL
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(calendar_id)
|
.bind(calendar_id)
|
||||||
@@ -346,7 +364,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
match row_opt {
|
match row_opt {
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
let event = CalendarEvent::with_id(
|
let mut event = CalendarEvent::with_id(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -364,6 +382,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
Ok(Some(event))
|
Ok(Some(event))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
@@ -375,12 +394,18 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
calendar_id: &Uuid,
|
calendar_id: &Uuid,
|
||||||
ical_uids: &[String],
|
ical_uids: &[String],
|
||||||
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>> {
|
||||||
|
// Batch UID lookup returns ALL rows for the given UIDs, both
|
||||||
|
// masters and exception overrides. Callers that want just
|
||||||
|
// masters filter downstream. Same phase-2 policy as the
|
||||||
|
// single-UID variant — read-side filtering is a phase-3
|
||||||
|
// concern; the DB unique indexes are what guarantee at most
|
||||||
|
// one master + N distinct exceptions per (calendar, UID).
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, calendar_id, summary, description, location,
|
id, calendar_id, summary, description, location,
|
||||||
start_time, end_time, all_day, rrule,
|
start_time, end_time, all_day, rrule,
|
||||||
created_at, updated_at, ical_uid, ical_data
|
created_at, updated_at, ical_uid, ical_data, recurrence_id
|
||||||
FROM caldav.calendar_events
|
FROM caldav.calendar_events
|
||||||
WHERE calendar_id = $1 AND ical_uid = ANY($2)
|
WHERE calendar_id = $1 AND ical_uid = ANY($2)
|
||||||
ORDER BY start_time
|
ORDER BY start_time
|
||||||
@@ -396,7 +421,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let event = CalendarEvent::with_id(
|
let mut event = CalendarEvent::with_id(
|
||||||
row.get("id"),
|
row.get("id"),
|
||||||
row.get("calendar_id"),
|
row.get("calendar_id"),
|
||||||
row.get("summary"),
|
row.get("summary"),
|
||||||
@@ -414,6 +439,7 @@ impl CalendarEventRepository for CalendarEventPgRepository {
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
DomainError::database_error(format!("Error creating calendar event: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
event.set_recurrence_id(row.get::<Option<DateTime<Utc>>, _>("recurrence_id"));
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user