From 91a853fd2403b74d70244418ce127a3ee8f6aba2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 10:40:53 -0500 Subject: [PATCH 01/11] docs: plan for resending calendar invites --- .../resend-calendar-invite.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/implementation-plans/resend-calendar-invite.md diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md new file mode 100644 index 000000000..bdc8a3a43 --- /dev/null +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -0,0 +1,43 @@ +# Resend calendar invite (backend) + +Lets a coach resend the calendar invite for a coaching session series or a one-off session, so a coachee who missed or declined part of a series can get the whole series back on their calendar. The frontend counterpart is `refactor-platform-fe/docs/implementation-plans/resend-calendar-invite.md`. + +Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one phase per commit. + +## Decisions + +- Recipients are coach and coachee, the same as the original invite. The resend uses the existing "scheduled" templates (`RecurringSessionsScheduled`, `SessionScheduled`), so no new templates or config are needed. +- Every resend bumps `ical_sequence` before it sends. The UID stays the same, so calendars update the existing event rather than adding a copy. +- A series resend carries a **snapshot**: the master VEVENT (RRULE plus an EXDATE for every slot with no member), plus one override VEVENT per member that was moved or resized. +- There is no server-side throttle. The endpoints are coach-only and the frontend puts them behind a confirm dialog. +- Legacy series (members with a NULL `ical_recurrence_id`) get a 409 from the series endpoint. The session endpoint accepts their members, which already go out standalone through `build_session_ics`. +- The send is synchronous. The endpoint answers 502 when the coachee wasn't reached. + +## Contract + +| Route | Auth | Responses | +|---|---|---| +| `POST /coaching_session_series/:id/resend_invite` | `CoachingSessionSeriesCoachAccess` | 204; 409 when the series is legacy or has no upcoming session; 502 when the coachee wasn't reached | +| `POST /coaching_sessions/:id/resend_invite` | coach of the session's relationship | 204; 409 when the session is a member of a non-legacy series; 502 when the coachee wasn't reached | +| `GET /users/:user_id/coaching_sessions?include=series` | existing | each series member carries `series: { id, last_session_date }`; one-offs have no `series` key | + +Both POSTs take no body. A 409 body carries a machine-readable reason (`legacy_series` / `no_upcoming_sessions` / `series_member`) so the frontend can choose the right toast. + +## Snapshot rules (B1) + +- Slots = `expand_recurrence(rule.start_at, &rule.recurrence)`, each truncated with `SessionDate`, exactly as `bulk_create_recurring` stores them in `ical_recurrence_id`. +- Members = sessions whose `coaching_session_series_id` is this series and whose `ical_recurrence_id` is one of the slots. A member whose recurrence id is not a slot (for example a past session left over from before a series reschedule) is ignored. +- Master: DTSTART = `rule.start_at`, duration = `rule.duration_minutes`, RRULE from `rule.recurrence`, SEQUENCE = `series.ical_sequence`, and EXDATE for every slot that has no member. +- Overrides: one per member where `date != ical_recurrence_id` or `duration_minutes != rule.duration_minutes`. RECURRENCE-ID is the slot, DTSTART is `date`, SEQUENCE is the session's `ical_sequence`. +- Everything goes in one VCALENDAR with `METHOD:REQUEST` and a single VTIMEZONE block. + +## Phases + +- [ ] B1: `ical.rs` gains EXDATE and multi-VEVENT support; a pure `series_snapshot` builder +- [ ] B2: `send_series_invite_email` returns `Delivered` +- [ ] B3: both `resend_invite` endpoints +- [ ] B4: `include=series` with `last_session_date` + +## Follow-ups + +- EXDATE and RECURRENCE-ID values are rendered in the coach's zone from UTC slots. If a series spans a DST change, a client that expands the RRULE in local time can place a slot one hour away from our stored UTC slot. This was already true for the existing RECURRENCE-ID path; it isn't new here. From f46499a3d660008e3feb9ff73db7d18cc5d5c1d8 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 10:49:48 -0500 Subject: [PATCH 02/11] feat(ical): build a snapshot invite for a whole series --- domain/src/coaching_session.rs | 12 ++ domain/src/emails.rs | 88 +++++++++++++++ domain/src/emails_tests.rs | 185 +++++++++++++++++++++++++++++++ domain/src/gateway/ical.rs | 146 +++++++++++++++--------- domain/src/gateway/ical_tests.rs | 85 ++++++++++++++ 5 files changed, 464 insertions(+), 52 deletions(-) diff --git a/domain/src/coaching_session.rs b/domain/src/coaching_session.rs index c8efeb763..c3f560138 100644 --- a/domain/src/coaching_session.rs +++ b/domain/src/coaching_session.rs @@ -182,6 +182,18 @@ pub async fn bulk_create_recurring( .await?) } +/// The original start of every occurrence a series rule produces, truncated exactly as +/// [`bulk_create_recurring`] stores them, so they compare equal to `ical_recurrence_id`. +pub(crate) fn series_slots( + start_at: NaiveDateTime, + recurrence: &Recurrence, +) -> Result, Error> { + expand_recurrence(start_at, recurrence)? + .into_iter() + .map(|d| SessionDate::new(d).map(SessionDate::into_inner)) + .collect() +} + /// Runs deferred side-effects (Tiptap doc, meeting URL, in-progress-goal links) /// on first read, stamping `hydrated_at` so subsequent reads short-circuit. /// diff --git a/domain/src/emails.rs b/domain/src/emails.rs index fd0cf7fce..adc3ff0a5 100644 --- a/domain/src/emails.rs +++ b/domain/src/emails.rs @@ -768,6 +768,7 @@ fn build_session_invite_ics( location_url: session.meeting_url.clone(), recurrence: None, recurrence_id: None, + exdates: Vec::new(), }; ical::build(&invite) } @@ -802,6 +803,7 @@ fn build_occurrence_reschedule_ics( location_url: session.meeting_url.clone(), recurrence: None, recurrence_id: session.ical_recurrence_id, + exdates: Vec::new(), }; ical::build(&invite) } @@ -1325,6 +1327,7 @@ fn build_session_cancel_ics( location_url: session.meeting_url.clone(), recurrence: None, recurrence_id: None, + exdates: Vec::new(), }; ical::build(&invite) } @@ -1359,6 +1362,7 @@ fn build_occurrence_cancel_ics( location_url: session.meeting_url.clone(), recurrence: None, recurrence_id: session.ical_recurrence_id, + exdates: Vec::new(), }; ical::build(&invite) } @@ -1606,10 +1610,93 @@ fn build_series_invite_ics( location_url: first_session.meeting_url.clone(), recurrence: Some(rule.recurrence), recurrence_id: None, + exdates: Vec::new(), }; ical::build(&invite) } +/// Build the series `.ics` as it stands today: the master (RRULE) with an EXDATE per +/// deleted slot, plus an override VEVENT per moved or resized member. A member is a row +/// whose `ical_recurrence_id` is one of the rule's slots; every other row is ignored. +/// Pure: `dtstamp` injected. +#[allow(dead_code)] +fn build_series_snapshot_ics( + coach: &users::Model, + coachee: &users::Model, + series: &coaching_session_series::Model, + members: &[coaching_sessions::Model], + description: String, + dtstamp: chrono::NaiveDateTime, +) -> Result { + let anchor_tz = anchor_tz(coach); + let rule: SeriesRule = serde_json::from_value(series.rule.clone())?; + let slots = coaching_session::series_slots(rule.start_at, &rule.recurrence)?; + let start = *slots.first().ok_or_else(|| Error { + source: None, + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other( + "Cannot build series snapshot: rule produced no slots".to_string(), + )), + })?; + + let slotted: Vec<(NaiveDateTime, Option<&coaching_sessions::Model>)> = slots + .iter() + .map(|slot| { + let member = members.iter().find(|m| m.ical_recurrence_id == Some(*slot)); + (*slot, member) + }) + .collect(); + + let base = || ical::IcsInvite { + uid: ics_uid(series.id), + sequence: series.ical_sequence, + method: ical::Method::Request, + status: ical::EventStatus::Confirmed, + summary: session_summary(coach, coachee), + description: description.clone(), + anchor_tz, + dtstamp, + start, + duration_minutes: rule.duration_minutes, + organizer: platform_organizer(), + attendees: session_attendees(coach, coachee), + location_url: None, + recurrence: None, + recurrence_id: None, + exdates: Vec::new(), + }; + + let master = ical::IcsInvite { + location_url: slotted + .iter() + .filter_map(|(_, member)| *member) + .min_by_key(|member| member.date) + .and_then(|member| member.meeting_url.clone()), + recurrence: Some(rule.recurrence.clone()), + exdates: slotted + .iter() + .filter(|(_, member)| member.is_none()) + .map(|(slot, _)| *slot) + .collect(), + ..base() + }; + + let overrides = slotted.iter().filter_map(|(slot, member)| { + member + .filter(|m| m.date != *slot || m.duration_minutes != rule.duration_minutes) + .map(|m| ical::IcsInvite { + sequence: m.ical_sequence, + start: m.date, + duration_minutes: m.duration_minutes, + location_url: m.meeting_url.clone(), + recurrence_id: Some(*slot), + ..base() + }) + }); + + let invites: Vec = std::iter::once(master).chain(overrides).collect(); + ical::build_calendar(&invites) +} + /// Send series invite emails to both coach and coachee. Generic over the notification /// type `N` so the scheduled and reschedule flows share one body; they differ only by /// template (via `N`) and the `session_or_series` template variable. A reschedule passes @@ -1916,6 +2003,7 @@ fn build_series_cancel_ics( location_url: first_session.meeting_url.clone(), recurrence: Some(rule.recurrence), recurrence_id: None, + exdates: Vec::new(), }; ical::build(&invite) } diff --git a/domain/src/emails_tests.rs b/domain/src/emails_tests.rs index a30299dbb..7150aa697 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -2444,6 +2444,191 @@ fn test_build_series_invite_ics_carries_bumped_sequence() { assert!(ics.contains("STATUS:CONFIRMED")); } +// ── Series Snapshot Tests ────────────────────────────────────────── + +/// A weekly, six-occurrence series starting at `start_at` (a Tuesday, UTC). +fn snapshot_series(start_at: NaiveDateTime) -> coaching_session_series::Model { + coaching_session_series::Model { + rule: serde_json::json!({ + "start_at": start_at, + "recurrence": { "frequency": "weekly", "interval": 1, "count": 6 }, + "duration_minutes": 60, + }), + ical_sequence: 1, + ..create_test_series() + } +} + +/// Tuesday Sep 15 2026, 19:00 UTC: 14:00 in Chicago (CDT) for all six weeks. +fn snapshot_start() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 9, 15) + .unwrap() + .and_hms_opt(19, 0, 0) + .unwrap() +} + +/// The six weekly slots from `start_at`. +fn snapshot_slots(start_at: NaiveDateTime) -> Vec { + (0..6) + .map(|week| start_at + chrono::Duration::weeks(week)) + .collect() +} + +/// One unmoved member per slot. +fn snapshot_members(series: &coaching_session_series::Model) -> Vec { + let rule: SeriesRule = serde_json::from_value(series.rule.clone()).unwrap(); + snapshot_slots(rule.start_at) + .into_iter() + .map(|slot| coaching_sessions::Model { + duration_minutes: 60, + meeting_url: Some("https://meet.example/series".into()), + ..create_test_series_session(series.id, slot) + }) + .collect() +} + +/// The unfolded snapshot for a Chicago coach. +fn snapshot( + series: &coaching_session_series::Model, + members: &[coaching_sessions::Model], +) -> String { + let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); + let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); + let dtstamp = NaiveDate::from_ymd_opt(2026, 9, 1) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap(); + let ics = build_series_snapshot_ics(&coach, &coachee, series, members, "desc".into(), dtstamp) + .unwrap(); + unfold(&ics) +} + +/// Every VEVENT span, in order. +fn vevents(ics: &str) -> Vec<&str> { + ics.match_indices("BEGIN:VEVENT") + .map(|(start, _)| { + let end = start + ics[start..].find("END:VEVENT").unwrap(); + &ics[start..end] + }) + .collect() +} + +#[test] +fn snapshot_untouched_series_is_master_only() { + let series = snapshot_series(snapshot_start()); + let ics = snapshot(&series, &snapshot_members(&series)); + + let events = vevents(&ics); + assert_eq!(events.len(), 1); + assert_eq!(ics.matches("EXDATE").count(), 0); + assert!(events[0].contains("RRULE:FREQ=WEEKLY;COUNT=6")); + assert!(events[0].contains("SEQUENCE:1")); + assert!(events[0].contains("LOCATION:https://meet.example/series")); +} + +#[test] +fn snapshot_deleted_slot_becomes_exdate() { + let series = snapshot_series(snapshot_start()); + let mut members = snapshot_members(&series); + members.remove(3); + let ics = snapshot(&series, &members); + + assert_eq!(ics.matches("EXDATE").count(), 1); + assert!(ics.contains("EXDATE;TZID=America/Chicago:20261006T140000")); + assert_eq!(vevents(&ics).len(), 1); +} + +#[test] +fn snapshot_moved_slot_becomes_override() { + let series = snapshot_series(snapshot_start()); + let mut members = snapshot_members(&series); + members[2].date += chrono::Duration::days(1); + members[2].ical_sequence = 4; + let ics = snapshot(&series, &members); + + let events = vevents(&ics); + assert_eq!(events.len(), 2); + let moved = events[1]; + assert!(moved.contains("RECURRENCE-ID;TZID=America/Chicago:20260929T140000")); + assert!(moved.contains("DTSTART;TZID=America/Chicago:20260930T140000")); + assert!(moved.contains("SEQUENCE:4")); + assert!(!moved.contains("RRULE")); + assert!(events[0].contains("SEQUENCE:1")); +} + +#[test] +fn snapshot_deleted_and_moved_together() { + let series = snapshot_series(snapshot_start()); + let mut members = snapshot_members(&series); + members[2].date += chrono::Duration::days(1); + members.remove(3); + let ics = snapshot(&series, &members); + + assert_eq!(ics.matches("EXDATE").count(), 1); + assert!(ics.contains("EXDATE;TZID=America/Chicago:20261006T140000")); + assert_eq!(vevents(&ics).len(), 2); +} + +/// Nothing is filtered by `now`: a moved past occurrence still needs its override. +#[test] +fn snapshot_past_moved_member_kept() { + // Tuesday Jan 7 2025, 19:00 UTC: 13:00 in Chicago (CST). + let start = NaiveDate::from_ymd_opt(2025, 1, 7) + .unwrap() + .and_hms_opt(19, 0, 0) + .unwrap(); + let series = snapshot_series(start); + let mut members = snapshot_members(&series); + members[0].date += chrono::Duration::days(1); + let ics = snapshot(&series, &members); + + let events = vevents(&ics); + assert_eq!(events.len(), 2); + assert!(events[1].contains("RECURRENCE-ID;TZID=America/Chicago:20250107T130000")); + assert!(events[1].contains("DTSTART;TZID=America/Chicago:20250108T130000")); +} + +#[test] +fn snapshot_ignores_non_slot_and_null_members() { + let series = snapshot_series(snapshot_start()); + let members = snapshot_members(&series); + let baseline = snapshot(&series, &members); + + let early = NaiveDate::from_ymd_opt(2026, 9, 1) + .unwrap() + .and_hms_opt(19, 0, 0) + .unwrap(); + let stray = |recurrence_id| coaching_sessions::Model { + coaching_session_series_id: Some(series.id), + ical_recurrence_id: recurrence_id, + date: early, + duration_minutes: 30, + meeting_url: Some("https://meet.example/stray".into()), + ical_sequence: 9, + ..create_test_session() + }; + let mut with_strays = members.clone(); + with_strays.push(stray(None)); + with_strays.push(stray(Some(snapshot_start() + chrono::Duration::hours(1)))); + + assert_eq!(snapshot(&series, &with_strays), baseline); +} + +#[test] +fn snapshot_master_dtstart_is_rule_start_even_if_first_moved() { + let series = snapshot_series(snapshot_start()); + let mut members = snapshot_members(&series); + members[0].date += chrono::Duration::days(1); + let ics = snapshot(&series, &members); + + let events = vevents(&ics); + assert_eq!(events.len(), 2); + assert!(events[0].contains("DTSTART;TZID=America/Chicago:20260915T140000")); + assert!(events[0].contains("RRULE:")); + assert!(events[1].contains("RECURRENCE-ID;TZID=America/Chicago:20260915T140000")); + assert!(events[1].contains("DTSTART;TZID=America/Chicago:20260916T140000")); +} + #[cfg(feature = "mock")] #[tokio::test] async fn test_send_recurring_sessions_scheduled_email_personalization() { diff --git a/domain/src/gateway/ical.rs b/domain/src/gateway/ical.rs index 3c4047ed2..4500f0f5e 100644 --- a/domain/src/gateway/ical.rs +++ b/domain/src/gateway/ical.rs @@ -16,6 +16,7 @@ use vtimezones_rs::VTIMEZONES; mod tests; /// VCALENDAR-level `METHOD`. +#[derive(PartialEq, Eq)] pub enum Method { Request, Cancel, @@ -62,6 +63,9 @@ pub struct IcsInvite<'a> { /// RFC 5545 `RECURRENCE-ID`: the original start of the occurrence this invite /// overrides. Mutually exclusive with `recurrence`. pub recurrence_id: Option, + /// RFC 5545 `EXDATE`s (UTC, like `start`): series occurrences that no longer exist. + /// Only emitted alongside an RRULE. + pub exdates: Vec, } /// One open action rendered into the DESCRIPTION. @@ -152,10 +156,71 @@ fn rrule_value(rule: &Recurrence) -> String { /// Build the full VCALENDAR text (with spliced VTIMEZONE when zoned) for one invite. pub fn build(invite: &IcsInvite) -> Result { - let method = match invite.method { + build_calendar(std::slice::from_ref(invite)) +} + +/// Build one VCALENDAR holding every invite as a VEVENT, in order, under a single +/// METHOD and a single VTIMEZONE. A series master and its overrides travel together. +pub fn build_calendar(invites: &[IcsInvite]) -> Result { + let first = invites + .first() + .ok_or_else(|| internal("no invites to build a calendar from"))?; + if invites.iter().any(|i| i.anchor_tz != first.anchor_tz) { + return Err(internal( + "invites in one calendar must share an anchor zone", + )); + } + if invites.iter().any(|i| i.method != first.method) { + return Err(internal("invites in one calendar must share a method")); + } + + let method = match first.method { Method::Request => "REQUEST", Method::Cancel => "CANCEL", }; + + let block = if first.anchor_tz == chrono_tz::UTC { + None + } else { + vtimezone_block(first.anchor_tz) + }; + if block.is_none() && first.anchor_tz != chrono_tz::UTC { + warn!( + "no VTIMEZONE block for zone {}, falling back to UTC", + first.anchor_tz.name() + ); + } + let zone = block.as_ref().map(|_| first.anchor_tz); + + let mut calendar = Calendar::new(); + calendar.append_property(("METHOD", method)); + invites.iter().for_each(|invite| { + calendar.push(event(invite, zone)); + }); + + let serialized = calendar.done().to_string(); + match block { + Some(block) => splice_vtimezone(&serialized, &block), + None => Ok(serialized), + } +} + +/// A date-time property in the same form as DTSTART: `TZID` + local wall time when +/// zoned, UTC otherwise. Clients match RECURRENCE-ID and EXDATE against DTSTART. +fn occurrence_property(name: &str, at: NaiveDateTime, zone: Option) -> Property { + match zone { + Some(tz) => { + let local = Utc.from_utc_datetime(&at).with_timezone(&tz).naive_local(); + Property::new(name, local.format(LOCAL_BASIC).to_string()) + .add_parameter("TZID", tz.name()) + .done() + } + None => Property::new(name, at.format(UTC_BASIC).to_string()), + } +} + +/// One VEVENT. `zone` is the anchor zone when a VTIMEZONE block accompanies it. +fn event(invite: &IcsInvite, zone: Option) -> Event { let status = match invite.status { EventStatus::Confirmed => "CONFIRMED", EventStatus::Cancelled => "CANCELLED", @@ -163,12 +228,6 @@ pub fn build(invite: &IcsInvite) -> Result { let end = invite.start + Duration::minutes(invite.duration_minutes as i64); - let block = if invite.anchor_tz == chrono_tz::UTC { - None - } else { - vtimezone_block(invite.anchor_tz) - }; - let mut event = Event::new(); event .uid(&invite.uid) @@ -178,45 +237,24 @@ pub fn build(invite: &IcsInvite) -> Result { .add_property("DTSTAMP", invite.dtstamp.format(UTC_BASIC).to_string()) .add_property("STATUS", status); - match &block { - Some(_) => { + match zone { + Some(tz) => { let start_local = Utc .from_utc_datetime(&invite.start) - .with_timezone(&invite.anchor_tz) - .naive_local(); - let end_local = Utc - .from_utc_datetime(&end) - .with_timezone(&invite.anchor_tz) + .with_timezone(&tz) .naive_local(); - event.starts(CalendarDateTime::from((start_local, invite.anchor_tz))); - event.ends(CalendarDateTime::from((end_local, invite.anchor_tz))); - // Must mirror DTSTART's form or clients fail to match the override. - if let Some(recurrence_id) = invite.recurrence_id { - let local = Utc - .from_utc_datetime(&recurrence_id) - .with_timezone(&invite.anchor_tz) - .naive_local(); - event.append_property( - Property::new("RECURRENCE-ID", local.format(LOCAL_BASIC).to_string()) - .add_parameter("TZID", invite.anchor_tz.name()) - .done(), - ); - } + let end_local = Utc.from_utc_datetime(&end).with_timezone(&tz).naive_local(); + event.starts(CalendarDateTime::from((start_local, tz))); + event.ends(CalendarDateTime::from((end_local, tz))); } None => { - if invite.anchor_tz != chrono_tz::UTC { - warn!( - "no VTIMEZONE block for zone {}, falling back to UTC", - invite.anchor_tz.name() - ); - } event.starts(Utc.from_utc_datetime(&invite.start)); event.ends(Utc.from_utc_datetime(&end)); - if let Some(recurrence_id) = invite.recurrence_id { - event.add_property("RECURRENCE-ID", recurrence_id.format(UTC_BASIC).to_string()); - } } } + if let Some(recurrence_id) = invite.recurrence_id { + event.append_property(occurrence_property("RECURRENCE-ID", recurrence_id, zone)); + } event.append_property( Property::new("ORGANIZER", format!("mailto:{}", invite.organizer.email)) @@ -248,25 +286,29 @@ pub fn build(invite: &IcsInvite) -> Result { } // An override instance addresses a single occurrence, so it carries no RRULE. - match (&invite.recurrence, invite.recurrence_id) { - (Some(rule), None) => { + let rrule = match (&invite.recurrence, invite.recurrence_id) { + (Some(rule), None) => Some(rule), + (Some(_), Some(_)) => { + warn!("dropping RRULE from an invite that carries a RECURRENCE-ID"); + None + } + (None, _) => None, + }; + // EXDATEs only mean something against an RRULE. + match rrule { + Some(rule) => { event.add_property("RRULE", rrule_value(rule)); + invite.exdates.iter().for_each(|exdate| { + event.append_multi_property(occurrence_property("EXDATE", *exdate, zone)); + }); } - (Some(_), Some(_)) => warn!("dropping RRULE from an invite that carries a RECURRENCE-ID"), - (None, _) => {} + None if !invite.exdates.is_empty() => { + warn!("dropping EXDATEs from an invite that carries no RRULE"); + } + None => {} } - let event = event.done(); - let calendar = Calendar::new() - .append_property(("METHOD", method)) - .push(event) - .done(); - - let serialized = calendar.to_string(); - match block { - Some(block) => splice_vtimezone(&serialized, &block), - None => Ok(serialized), - } + event.done() } /// Compose the DESCRIPTION string. Each section is omitted when its data is empty. diff --git a/domain/src/gateway/ical_tests.rs b/domain/src/gateway/ical_tests.rs index f57037d73..82403ac30 100644 --- a/domain/src/gateway/ical_tests.rs +++ b/domain/src/gateway/ical_tests.rs @@ -41,6 +41,7 @@ fn invite<'a>( location_url: None, recurrence, recurrence_id: None, + exdates: Vec::new(), } } @@ -256,6 +257,90 @@ fn override_instance_never_carries_an_rrule() { assert!(out.contains("RECURRENCE-ID;TZID=America/New_York:20260908T150000")); } +fn weekly_count(count: u32) -> Recurrence { + Recurrence { + frequency: Frequency::Weekly, + interval: 1, + by_weekdays: None, + count: Some(count), + until: None, + } +} + +/// Clients match an EXDATE against DTSTART, so it must use the same zoned or `Z` form. +#[test] +fn exdate_matches_dtstart_timezone_form() { + let mut zoned = invite( + person(COACH), + person(COACHEE), + chrono_tz::America::Chicago, + Some(weekly_count(6)), + ); + zoned.exdates = vec![dt(2026, 9, 22, 19, 0), dt(2026, 10, 6, 19, 0)]; + let out = build(&zoned).unwrap(); + assert_eq!(out.matches("EXDATE;TZID=America/Chicago:").count(), 2); + assert!(out.contains("EXDATE;TZID=America/Chicago:20260922T140000")); + assert!(out.contains("EXDATE;TZID=America/Chicago:20261006T140000")); + + let mut utc = invite( + person(COACH), + person(COACHEE), + chrono_tz::UTC, + Some(weekly_count(6)), + ); + utc.exdates = vec![dt(2026, 9, 22, 19, 0)]; + let out = build(&utc).unwrap(); + assert!(out.contains("EXDATE:20260922T190000Z")); +} + +/// EXDATEs only mean something against an RRULE, which an override never carries. +#[test] +fn exdates_dropped_without_rrule() { + let mut inv = invite( + person(COACH), + person(COACHEE), + New_York, + Some(weekly_count(6)), + ); + inv.recurrence_id = Some(dt(2026, 9, 8, 19, 0)); + inv.exdates = vec![dt(2026, 9, 22, 19, 0)]; + let out = build(&inv).unwrap(); + assert!(!out.contains("EXDATE")); +} + +#[test] +fn build_calendar_multiple_events_single_vtimezone() { + let master = invite( + person(COACH), + person(COACHEE), + New_York, + Some(weekly_count(6)), + ); + let mut moved = invite(person(COACH), person(COACHEE), New_York, None); + moved.recurrence_id = Some(dt(2026, 9, 22, 19, 0)); + moved.start = dt(2026, 9, 23, 19, 0); + let out = build_calendar(&[master, moved]).unwrap(); + + assert_eq!(out.matches("BEGIN:VEVENT").count(), 2); + assert_eq!(out.matches("BEGIN:VTIMEZONE").count(), 1); + assert!(out.find("BEGIN:VTIMEZONE").unwrap() < out.find("BEGIN:VEVENT").unwrap()); + assert_eq!(out.matches("METHOD:REQUEST").count(), 1); +} + +#[test] +fn build_calendar_rejects_empty_and_mixed_zones() { + assert!(build_calendar(&[]).is_err()); + + let ny = invite(person(COACH), person(COACHEE), New_York, None); + let chicago = invite( + person(COACH), + person(COACHEE), + chrono_tz::America::Chicago, + None, + ); + assert!(build_calendar(&[ny, chicago]).is_err()); +} + /// RFC 5545 line unfolding: remove the CRLF followed by a leading space/tab. fn unfold(s: &str) -> String { s.replace("\r\n ", "").replace("\r\n\t", "") From 6ba8f08fa128c7aae06c6a5a5b3c8345250b93ab Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 10:55:20 -0500 Subject: [PATCH 03/11] feat(emails): report series invite delivery and allow a snapshot attachment --- domain/src/emails.rs | 102 ++++++++++++++++++---- domain/src/emails_tests.rs | 170 +++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 18 deletions(-) diff --git a/domain/src/emails.rs b/domain/src/emails.rs index adc3ff0a5..9dfe85594 100644 --- a/domain/src/emails.rs +++ b/domain/src/emails.rs @@ -539,9 +539,9 @@ pub fn affects_schedule(old: &coaching_sessions::Model, new: &coaching_sessions: /// The two people on a coaching session, carried as one named value rather than as an /// adjacent pair of `&users::Model`. A transposed pair still type-checks and would swap /// every "your coach" / "your coachee" phrase in the copy without failing a build. -struct Participants<'a> { - coach: &'a users::Model, - coachee: &'a users::Model, +pub(crate) struct Participants<'a> { + pub(crate) coach: &'a users::Model, + pub(crate) coachee: &'a users::Model, } /// One addressee of a per-recipient send, plus the other party as that recipient sees them. @@ -1619,7 +1619,6 @@ fn build_series_invite_ics( /// deleted slot, plus an override VEVENT per moved or resized member. A member is a row /// whose `ical_recurrence_id` is one of the rule's slots; every other row is ignored. /// Pure: `dtstamp` injected. -#[allow(dead_code)] fn build_series_snapshot_ics( coach: &users::Model, coachee: &users::Model, @@ -1697,6 +1696,21 @@ fn build_series_snapshot_ics( ical::build_calendar(&invites) } +/// Which `.ics` a series email carries. +enum SeriesInviteShape<'a> { + /// The master VEVENT only (create and reschedule). + Master, + /// The series as it stands today, built from every row linked to the series. + Snapshot(&'a [coaching_sessions::Model]), +} + +/// What kind of series email this is: the reschedule template variables, if any, and +/// which `.ics` it carries. +struct SeriesInviteKind<'a> { + reschedule: Option<&'a RescheduleVars>, + shape: SeriesInviteShape<'a>, +} + /// Send series invite emails to both coach and coachee. Generic over the notification /// type `N` so the scheduled and reschedule flows share one body; they differ only by /// template (via `N`) and the `session_or_series` template variable. A reschedule passes @@ -1709,9 +1723,10 @@ async fn send_series_invite_email( participants: &Participants<'_>, sessions: &SeriesSessions<'_>, organization: &organizations::Model, - reschedule: Option<&RescheduleVars>, -) -> Result<(), Error> { + kind: SeriesInviteKind<'_>, +) -> Result { let Participants { coach, coachee } = *participants; + let SeriesInviteKind { reschedule, shape } = kind; let SeriesSessions { current: sessions, replaced, @@ -1756,17 +1771,24 @@ async fn send_series_invite_email( }); let dtstamp = chrono::Utc::now().naive_utc(); - let ics = SeriesIcs { - body: build_series_invite_ics(coach, coachee, first, series, description, dtstamp)?, - // The replaced rows are gone from the database, but any standalone events they put - // on calendars outlive them and are addressed by nothing the new series carries. - orphan_cancels: build_orphan_cancels(participants, replaced, dtstamp), + let ics = match shape { + SeriesInviteShape::Master => SeriesIcs { + body: build_series_invite_ics(coach, coachee, first, series, description, dtstamp)?, + // The replaced rows are gone from the database, but any standalone events they + // put on calendars outlive them and are addressed by nothing the new series carries. + orphan_cancels: build_orphan_cancels(participants, replaced, dtstamp), + }, + SeriesInviteShape::Snapshot(rows) => SeriesIcs { + body: build_series_snapshot_ics(coach, coachee, series, rows, description, dtstamp)?, + orphan_cancels: Vec::new(), + }, }; // Timezone-independent, so it is computed once rather than per recipient. let current_rule: SeriesRule = serde_json::from_value(series.rule.clone())?; let recurrence_summary = current_rule.recurrence.summary(); + let mut delivered = Delivered::default(); if let Err(e) = send_recurring_series_email_to_recipient( &email_config, &Recipient { @@ -1787,6 +1809,8 @@ async fn send_series_invite_email( N::notification_name(), coachee.email ); + } else { + delivered.coachee = true; } if let Err(e) = send_recurring_series_email_to_recipient( @@ -1809,9 +1833,11 @@ async fn send_series_invite_email( N::notification_name(), coach.email ); + } else { + delivered.coach = true; } - Ok(()) + Ok(delivered) } /// Send recurring-sessions-scheduled notification emails to both coach and coachee. @@ -1834,7 +1860,43 @@ async fn send_recurring_sessions_scheduled_email( replaced: &[], }, organization, - None, + SeriesInviteKind { + reschedule: None, + shape: SeriesInviteShape::Master, + }, + ) + .await + .map(|_| ()) +} + +/// Resend a series' invite as it stands today. `series` must already carry the bumped +/// `ical_sequence`. `upcoming` is what the email lists; `rows` is every session linked +/// to the series, which the snapshot is built from. +// No caller until the resend endpoint lands. +#[allow(dead_code)] +pub(crate) async fn send_series_invite_resend( + db: &DatabaseConnection, + config: &Config, + series: &coaching_session_series::Model, + participants: &Participants<'_>, + upcoming: &[coaching_sessions::Model], + rows: &[coaching_sessions::Model], + organization: &organizations::Model, +) -> Result { + send_series_invite_email::( + db, + config, + series, + participants, + &SeriesSessions { + current: upcoming, + replaced: &[], + }, + organization, + SeriesInviteKind { + reschedule: None, + shape: SeriesInviteShape::Snapshot(rows), + }, ) .await } @@ -1862,13 +1924,17 @@ async fn send_recurring_sessions_rescheduled_email( participants, sessions, organization, - Some(&RescheduleVars { - session_or_series: "series", - previous_start: previous_rule.start_at, - previous_recurrence_summary: Some(previous_rule.recurrence.summary()), - }), + SeriesInviteKind { + reschedule: Some(&RescheduleVars { + session_or_series: "series", + previous_start: previous_rule.start_at, + previous_recurrence_summary: Some(previous_rule.recurrence.summary()), + }), + shape: SeriesInviteShape::Master, + }, ) .await + .map(|_| ()) } /// Orchestrate sending recurring-sessions-scheduled emails (best-effort). diff --git a/domain/src/emails_tests.rs b/domain/src/emails_tests.rs index 7150aa697..ee67165aa 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -3336,6 +3336,176 @@ async fn test_rescheduling_a_series_clears_the_standalone_events_it_replaced() { ); } +/// Loader for the series `.ics` description: the first session's in-progress goals. +#[cfg(feature = "mock")] +fn mock_series_description_loader() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results::(vec![vec![]]) + .into_connection() +} + +/// A series with slot 4 deleted and slot 3 moved a day later. +#[cfg(feature = "mock")] +fn edited_series_rows(series: &coaching_session_series::Model) -> Vec { + let mut rows = snapshot_members(series); + rows[2].date += chrono::Duration::days(1); + rows.remove(3); + rows +} + +/// Mocks one recipient's send, told apart by the `to` address. +#[cfg(feature = "mock")] +async fn mock_send_to( + server: &mut ServerGuard, + email: &str, + status: usize, + capture: &AttachmentsCapture, +) -> mockito::Mock { + server + .mock("POST", "/emails") + .match_body(mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#))) + .match_request(capture.recorder()) + .with_status(status) + .with_body(r#"{"id":"email_test"}"#) + .expect(1) + .create_async() + .await +} + +#[cfg(feature = "mock")] +#[tokio::test] +async fn series_resend_attaches_snapshot() { + let mut server = setup_test_server().await; + let config = create_full_config_with_mock(&server.url()); + let capture = AttachmentsCapture::default(); + + let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); + let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); + let org = create_test_organization(); + let series = snapshot_series(snapshot_start()); + let rows = edited_series_rows(&series); + + let mock_coachee = mock_send_to(&mut server, "jane@example.com", 200, &capture).await; + let mock_coach = mock_send_to(&mut server, "alex@example.com", 200, &capture).await; + + let delivered = send_series_invite_resend( + &mock_series_description_loader(), + &config, + &series, + &Participants { + coach: &coach, + coachee: &coachee, + }, + &rows, + &rows, + &org, + ) + .await + .unwrap(); + + mock_coachee.assert_async().await; + mock_coach.assert_async().await; + assert_eq!( + delivered, + Delivered { + coachee: true, + coach: true + } + ); + + let attachments = capture.captured(); + assert_eq!(attachments.len(), 1); + let ics = &attachments[0].1; + assert!(ics.contains("METHOD:REQUEST")); + assert_eq!(ics.matches("EXDATE").count(), 1); + assert_eq!(ics.matches("BEGIN:VEVENT").count(), 2); + assert!(ics.contains("RECURRENCE-ID")); +} + +#[cfg(feature = "mock")] +#[tokio::test] +async fn series_resend_reports_coachee_failure() { + let mut server = setup_test_server().await; + let config = create_full_config_with_mock(&server.url()); + let capture = AttachmentsCapture::default(); + + let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); + let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); + let org = create_test_organization(); + let series = snapshot_series(snapshot_start()); + let rows = edited_series_rows(&series); + + let mock_coachee = mock_send_to(&mut server, "jane@example.com", 400, &capture).await; + let mock_coach = mock_send_to(&mut server, "alex@example.com", 200, &capture).await; + + let delivered = send_series_invite_resend( + &mock_series_description_loader(), + &config, + &series, + &Participants { + coach: &coach, + coachee: &coachee, + }, + &rows, + &rows, + &org, + ) + .await + .unwrap(); + + mock_coachee.assert_async().await; + mock_coach.assert_async().await; + assert_eq!( + delivered, + Delivered { + coachee: false, + coach: true + } + ); +} + +#[cfg(feature = "mock")] +#[tokio::test] +async fn series_scheduled_still_master_only() { + let mut server = setup_test_server().await; + let config = create_full_config_with_mock(&server.url()); + let capture = AttachmentsCapture::default(); + + let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); + let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); + let org = create_test_organization(); + let series = snapshot_series(snapshot_start()); + let rows = edited_series_rows(&series); + + let mock = server + .mock("POST", "/emails") + .match_request(capture.recorder()) + .with_status(200) + .with_body(r#"{"id":"email_test"}"#) + .expect(2) + .create_async() + .await; + + let result = send_recurring_sessions_scheduled_email( + &mock_series_description_loader(), + &config, + &series, + &coach, + &coachee, + &rows, + &org, + ) + .await; + assert!(result.is_ok()); + mock.assert_async().await; + + let attachments = capture.captured(); + assert_eq!(attachments.len(), 1); + let ics = &attachments[0].1; + assert_eq!(ics.matches("EXDATE").count(), 0); + assert_eq!(ics.matches("BEGIN:VEVENT").count(), 1); +} + /// A reschedule can legitimately leave no future occurrences. There is then no series /// invite to attach anything to, but the rows it replaced are still deleted, and any /// standalone event they left is addressed by nothing else. Returning early here strands From ec6249197f2ebfb1c6f6805b39ad96ebce07c61e Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 11:12:15 -0500 Subject: [PATCH 04/11] feat: let a coach resend a series or session calendar invite --- Cargo.lock | 1 + domain/src/coaching_session.rs | 60 +- domain/src/coaching_session_series.rs | 45 ++ domain/src/emails.rs | 48 +- web/Cargo.toml | 1 + .../controller/coaching_session_controller.rs | 32 + .../coaching_session_series_controller.rs | 29 + web/src/controller/mod.rs | 5 + web/src/controller/resend_invite_tests.rs | 679 ++++++++++++++++++ web/src/protect/coaching_sessions.rs | 80 ++- web/src/router.rs | 18 + 11 files changed, 961 insertions(+), 37 deletions(-) create mode 100644 web/src/controller/resend_invite_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 48b0ca9ce..225f9993f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6108,6 +6108,7 @@ dependencies = [ "log", "meeting-ai", "meeting-auth", + "mockito", "password-auth", "regex", "reqwest", diff --git a/domain/src/coaching_session.rs b/domain/src/coaching_session.rs index c3f560138..128501f19 100644 --- a/domain/src/coaching_session.rs +++ b/domain/src/coaching_session.rs @@ -7,7 +7,7 @@ use crate::coaching_session_hydration::{ use crate::coaching_session_image; use crate::coaching_sessions::Model; use crate::emails; -use crate::error::{DomainErrorKind, EntityErrorKind, Error, InternalErrorKind}; +use crate::error::{DomainErrorKind, EntityErrorKind, Error, ExternalErrorKind, InternalErrorKind}; use crate::events::{DomainEvent, EventPublisher}; use crate::gateway::tiptap::TiptapDocument; use crate::meeting_provider::MeetingProperties; @@ -458,6 +458,64 @@ pub async fn delete(db: &DatabaseConnection, config: &Config, id: Id) -> Result< Ok(()) } +/// Resend a session's invite to both participants, under a bumped SEQUENCE so calendars +/// update the event they already hold. A member of a current series is refused: its +/// invite is the series', so it is resent from there. A legacy member never had a series +/// invite and goes out standalone. +pub async fn resend_invite(db: &DatabaseConnection, config: &Config, id: Id) -> Result<(), Error> { + let session = find_by_id(db, id).await?; + if session.date <= chrono::Utc::now().naive_utc() { + return Err(resend_refused( + "This session has already happened.", + "no_upcoming_sessions", + )); + } + if session.coaching_session_series_id.is_some() && session.ical_recurrence_id.is_some() { + return Err(resend_refused( + "This session's invite is part of its series.", + "series_member", + )); + } + + // Committed before the send: a failed send leaves a gap in SEQUENCE, which is harmless. + let bumped = coaching_session::increment_ical_sequence(db, id).await?; + let parties = emails::InviteParties::load(db, bumped.coaching_relationship_id).await?; + let delivered = emails::send_session_invite_resend( + db, + config, + &parties.participants(), + &bumped, + &parties.organization, + ) + .await?; + ensure_coachee_reached(&delivered) +} + +/// A resend that cannot apply, as a 409 carrying a machine-readable `reason`. +pub(crate) fn resend_refused(message: &str, reason: &str) -> Error { + Error { + source: None, + error_kind: DomainErrorKind::Internal(InternalErrorKind::Entity( + EntityErrorKind::Conflict { + message: message.to_string(), + details: Some(serde_json::json!({ "reason": reason })), + }, + )), + } +} + +/// A resend is only done once the coachee has it. Missing the coach is not a failure: the +/// coach is the one asking. +pub(crate) fn ensure_coachee_reached(delivered: &emails::Delivered) -> Result<(), Error> { + match delivered.coachee { + true => Ok(()), + false => Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + }), + } +} + /// If a provider is specified on the session, attempt to attach a meeting URL. First checks /// if an existing meeting URL can be reused (for providers with persistent URLs), then /// falls back to creating a new meeting space via OAuth credentials. diff --git a/domain/src/coaching_session_series.rs b/domain/src/coaching_session_series.rs index 176ce695c..31c29aa97 100644 --- a/domain/src/coaching_session_series.rs +++ b/domain/src/coaching_session_series.rs @@ -332,6 +332,51 @@ pub async fn delete_with_future_sessions( Ok(()) } +/// Resend a series' invite to both participants as it stands today, under a bumped +/// SEQUENCE so calendars replace the recurring event they hold. Refused when nothing is +/// left to attend, or when an upcoming member predates `ical_recurrence_id`: such a series +/// was never published as one, so a snapshot would not line up with what calendars hold. +pub async fn resend_invite( + db: &DatabaseConnection, + config: &Config, + series_id: Id, +) -> Result<(), Error> { + let series = coaching_session_series::find_by_id(db, series_id).await?; + let rows = entity_api::coaching_session::find_by_series_id(db, series_id).await?; + let now = chrono::Utc::now().naive_utc(); + let mut upcoming: Vec = + rows.iter().filter(|s| s.date > now).cloned().collect(); + upcoming.sort_by_key(|s| s.date); + + if upcoming.is_empty() { + return Err(coaching_session::resend_refused( + "This series has no upcoming sessions.", + "no_upcoming_sessions", + )); + } + if upcoming.iter().any(|s| s.ical_recurrence_id.is_none()) { + return Err(coaching_session::resend_refused( + "This series predates resendable invites.", + "legacy_series", + )); + } + + // Committed before the send: a failed send leaves a gap in SEQUENCE, which is harmless. + let bumped = coaching_session_series::increment_ical_sequence(db, series.id).await?; + let parties = emails::InviteParties::load(db, bumped.coaching_relationship_id).await?; + let delivered = emails::send_series_invite_resend( + db, + config, + &bumped, + &parties.participants(), + &upcoming, + &rows, + &parties.organization, + ) + .await?; + coaching_session::ensure_coachee_reached(&delivered) +} + async fn cleanup_orphaned_docs( config: &Config, series_id: Id, diff --git a/domain/src/emails.rs b/domain/src/emails.rs index 9dfe85594..eff3ec4de 100644 --- a/domain/src/emails.rs +++ b/domain/src/emails.rs @@ -1872,8 +1872,6 @@ async fn send_recurring_sessions_scheduled_email( /// Resend a series' invite as it stands today. `series` must already carry the bumped /// `ical_sequence`. `upcoming` is what the email lists; `rows` is every session linked /// to the series, which the snapshot is built from. -// No caller until the resend endpoint lands. -#[allow(dead_code)] pub(crate) async fn send_series_invite_resend( db: &DatabaseConnection, config: &Config, @@ -1901,6 +1899,52 @@ pub(crate) async fn send_series_invite_resend( .await } +/// Resend a session's invite as it stands today. `session` must already carry the bumped +/// `ical_sequence`. +pub(crate) async fn send_session_invite_resend( + db: &DatabaseConnection, + config: &Config, + participants: &Participants<'_>, + session: &coaching_sessions::Model, + organization: &organizations::Model, +) -> Result { + send_single_session_invite_email::( + db, + config, + participants.coach, + participants.coachee, + session, + organization, + None, + ) + .await +} + +/// Who an invite for a relationship goes to, and the organization it names. +pub(crate) struct InviteParties { + coach: users::Model, + coachee: users::Model, + pub(crate) organization: organizations::Model, +} + +impl InviteParties { + pub(crate) async fn load(db: &DatabaseConnection, relationship_id: Id) -> Result { + let relationship = coaching_relationship::find_by_id(db, relationship_id).await?; + Ok(Self { + coach: user::find_by_id_without_roles(db, relationship.coach_id).await?, + coachee: user::find_by_id_without_roles(db, relationship.coachee_id).await?, + organization: organization::find_by_id(db, relationship.organization_id).await?, + }) + } + + pub(crate) fn participants(&self) -> Participants<'_> { + Participants { + coach: &self.coach, + coachee: &self.coachee, + } + } +} + /// Send series reschedule notification emails to both coach and coachee. `series` must /// already carry the bumped `ical_sequence`, so the invite updates the recurring calendar /// event in place (same `UID`, next `SEQUENCE`). The previous start comes from diff --git a/web/Cargo.toml b/web/Cargo.toml index 8b6d01f67..735c62f1b 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -57,6 +57,7 @@ regex = "1" base64 = "0.22" chrono = { version = "0.4.38", features = ["serde"] } hmac = "0.12" +mockito = "1.6" password-auth = "1.0.0" reqwest = { version = "0.12.12", features = ["json", "cookies"] } sha2 = "0.10" diff --git a/web/src/controller/coaching_session_controller.rs b/web/src/controller/coaching_session_controller.rs index c309a050e..7fc365f4d 100644 --- a/web/src/controller/coaching_session_controller.rs +++ b/web/src/controller/coaching_session_controller.rs @@ -286,6 +286,38 @@ pub async fn delete( Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ()))) } +/// POST resend a Coaching Session's invite to both participants. Synchronous, so the +/// answer says whether it went. +#[utoipa::path( + post, + path = "/coaching_sessions/{id}/resend_invite", + params(ApiVersion, ("id" = Uuid, Path, description = "Coaching Session ID")), + responses( + (status = 204, description = "Invite resent"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden — not the coach on the relationship"), + (status = 404, description = "Coaching Session not found"), + (status = 409, description = "Not resendable; `details.reason` is `no_upcoming_sessions` or `series_member`"), + (status = 502, description = "The invite did not reach the coachee"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn resend_invite( + CompareApiVersion(_v): CompareApiVersion, + State(app_state): State, + Path(coaching_session_id): Path, +) -> Result { + CoachingSessionApi::resend_invite( + app_state.db_conn_ref(), + &app_state.config, + coaching_session_id, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + #[cfg(test)] #[cfg(feature = "mock")] mod tests { diff --git a/web/src/controller/coaching_session_series_controller.rs b/web/src/controller/coaching_session_series_controller.rs index eeb7966f9..8da0677a7 100644 --- a/web/src/controller/coaching_session_series_controller.rs +++ b/web/src/controller/coaching_session_series_controller.rs @@ -213,6 +213,35 @@ pub async fn delete( Ok(StatusCode::NO_CONTENT) } +/// `POST /coaching_session_series/:id/resend_invite` — resend the series invite to both +/// participants as it stands today. Synchronous, so the answer says whether it went. +#[utoipa::path( + post, + path = "/coaching_session_series/{id}/resend_invite", + params( + ApiVersion, + ("id" = Uuid, Path, description = "Series ID") + ), + responses( + (status = 204, description = "Invite resent"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden — not the coach on the relationship"), + (status = 404, description = "Not found"), + (status = 409, description = "Not resendable; `details.reason` is `no_upcoming_sessions` or `legacy_series`"), + (status = 502, description = "The invite did not reach the coachee") + ), + security(("cookie_auth" = [])) +)] +pub async fn resend_invite( + CompareApiVersion(_v): CompareApiVersion, + State(app_state): State, + CoachingSessionSeriesCoachAccess(series): CoachingSessionSeriesCoachAccess, +) -> Result { + CoachingSessionSeriesApi::resend_invite(app_state.db_conn_ref(), &app_state.config, series.id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + #[cfg(test)] #[cfg(feature = "mock")] mod tests { diff --git a/web/src/controller/mod.rs b/web/src/controller/mod.rs index bdf1d5c92..f19928c1e 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -30,6 +30,11 @@ mod coaching_session_view_tests; #[path = "user_lookup_tests.rs"] mod user_lookup_tests; +#[cfg(test)] +#[cfg(feature = "mock")] +#[path = "resend_invite_tests.rs"] +mod resend_invite_tests; + #[derive(Debug, Serialize)] struct ApiResponse { status_code: u16, diff --git a/web/src/controller/resend_invite_tests.rs b/web/src/controller/resend_invite_tests.rs new file mode 100644 index 000000000..08ac2ee95 --- /dev/null +++ b/web/src/controller/resend_invite_tests.rs @@ -0,0 +1,679 @@ +//! `POST /coaching_session_series/:id/resend_invite` and +//! `POST /coaching_sessions/:id/resend_invite`, driven through the real routers with a +//! logged-in session, a mock database, and a mock Resend. + +use std::sync::{Arc, Mutex}; + +use axum::{body::Body, extract::Request, http::StatusCode, routing::post, Router}; +use axum_login::{ + tower_sessions::{MemoryStore, SessionManagerLayer}, + AuthManagerLayerBuilder, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use chrono::{NaiveDateTime, Utc}; +use domain::user::Backend; +use domain::{ + actions, coaching_relationships, coaching_session_series, coaching_session_topics, + coaching_sessions, goals, organizations, user_roles, users, Id, +}; +use mockito::{Mock, Server, ServerGuard}; +use password_auth::generate_hash; +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; +use service::config::Config; +use time::Duration; +use tower::ServiceExt; +use tower_sessions::Expiry; + +use crate::controller::user_session_controller; +use crate::router::{coaching_session_series_routes, coaching_sessions_routes}; +use crate::AppState; + +const COACH_EMAIL: &str = "coach@example.com"; +const COACHEE_EMAIL: &str = "coachee@example.com"; + +fn user(email: &str) -> users::Model { + let now = Utc::now(); + users::Model { + id: Id::new_v4(), + email: email.to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + display_name: None, + password: Some(generate_hash("password123")), + github_username: None, + github_profile_url: None, + timezone: "UTC".to_string(), + default_coaching_session_duration_minutes: domain::duration::Duration::default_minutes(), + roles: vec![], + invite_status: None, + created_at: now.into(), + updated_at: now.into(), + } +} + +/// A coach and coachee in one organization, and the relationship between them. +struct Fixture { + coach: users::Model, + coachee: users::Model, + organization: organizations::Model, + relationship: coaching_relationships::Model, +} + +impl Fixture { + fn new() -> Self { + let now = Utc::now(); + let coach = user(COACH_EMAIL); + let coachee = user(COACHEE_EMAIL); + let organization = organizations::Model { + id: Id::new_v4(), + name: "Acme".to_string(), + logo: None, + slug: "acme".to_string(), + created_at: now.into(), + updated_at: now.into(), + archived_at: None, + archived_by: None, + }; + let relationship = coaching_relationships::Model { + id: Id::new_v4(), + organization_id: organization.id, + coach_id: coach.id, + coachee_id: coachee.id, + slug: "test".to_string(), + created_at: now.into(), + updated_at: now.into(), + }; + Self { + coach, + coachee, + organization, + relationship, + } + } + + fn role(&self, user: &users::Model) -> user_roles::Model { + let now = Utc::now(); + user_roles::Model { + id: Id::new_v4(), + role: users::Role::User, + organization_id: Some(self.organization.id), + user_id: user.id, + created_at: now.into(), + updated_at: now.into(), + } + } + + /// The login and `require_auth` loads for `caller`. + fn logged_in_as(&self, caller: &users::Model) -> MockDatabase { + let row = (caller.clone(), self.role(caller)); + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![row.clone()]]) + .append_query_results([vec![row]]) + } + + /// The loads `InviteParties::load` makes. + fn with_parties(&self, db: MockDatabase) -> MockDatabase { + db.append_query_results(vec![vec![self.relationship.clone()]]) + .append_query_results(vec![vec![self.coach.clone()]]) + .append_query_results(vec![vec![self.coachee.clone()]]) + .append_query_results(vec![vec![self.organization.clone()]]) + } + + fn series( + &self, + start_at: NaiveDateTime, + ical_sequence: i32, + ) -> coaching_session_series::Model { + let now = Utc::now(); + coaching_session_series::Model { + id: Id::new_v4(), + coaching_relationship_id: self.relationship.id, + rule: serde_json::json!({ + "start_at": start_at, + "recurrence": { "frequency": "weekly", "interval": 1, "count": 3 }, + "duration_minutes": 60, + }), + ical_sequence, + created_by_user_id: self.coach.id, + created_at: now.into(), + updated_at: now.into(), + } + } + + fn session(&self, date: NaiveDateTime) -> coaching_sessions::Model { + let now = Utc::now(); + coaching_sessions::Model { + id: Id::new_v4(), + coaching_relationship_id: self.relationship.id, + coaching_session_series_id: None, + ical_sequence: 2, + ical_recurrence_id: None, + collab_document_name: None, + date, + duration_minutes: 60, + title: None, + meeting_url: None, + provider: None, + created_at: now.into(), + updated_at: now.into(), + hydrated_at: None, + notice_given_at: now.into(), + } + } + + /// One member per slot of `series`, each in the slot it was created for. + fn members(&self, series: &coaching_session_series::Model) -> Vec { + let start_at: NaiveDateTime = + serde_json::from_value(series.rule["start_at"].clone()).unwrap(); + (0..3) + .map(|week| start_at + chrono::Duration::weeks(week)) + .map(|slot| coaching_sessions::Model { + coaching_session_series_id: Some(series.id), + ical_recurrence_id: Some(slot), + ..self.session(slot) + }) + .collect() + } +} + +/// A whole-hour start a week out, so it is upcoming and minute-aligned. +fn upcoming_start() -> NaiveDateTime { + (Utc::now() + chrono::Duration::days(7)) + .date_naive() + .and_hms_opt(15, 0, 0) + .unwrap() +} + +fn past_start() -> NaiveDateTime { + (Utc::now() - chrono::Duration::days(30)) + .date_naive() + .and_hms_opt(15, 0, 0) + .unwrap() +} + +fn config(resend_url: &str) -> Config { + Config::from_args([ + "test", + "--resend-api-key=test_api_key_123", + "--session-scheduled-email-template-id=session_template", + "--recurring-sessions-scheduled-email-template-id=series_template", + "--frontend-base-url=https://app.example.com", + &format!("--resend-base-url={resend_url}"), + ]) +} + +/// Logs `caller_email` in over the real routers, then sends the resend `POST`. +async fn post_resend( + db: Arc, + config: Config, + caller_email: &str, + uri: String, +) -> axum::response::Response { + let app_state = AppState::new( + service::AppState::new(config, &db), + Arc::new(sse::Manager::default()), + domain::events::EventPublisher::default(), + None, + None, + None, + ); + let session_layer = SessionManagerLayer::new(MemoryStore::default()) + .with_secure(false) + .with_expiry(Expiry::OnInactivity(Duration::days(1))) + .with_always_save(true); + let auth_layer = AuthManagerLayerBuilder::new(Backend::new(&db), session_layer).build(); + + let app = Router::new() + .route("/login", post(user_session_controller::login)) + .with_state(app_state.clone()) + .merge(coaching_sessions_routes(app_state.clone())) + .merge(coaching_session_series_routes(app_state)) + .layer(auth_layer); + + let login = Request::builder() + .uri("/login") + .method("POST") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(format!( + "email={caller_email}&password=password123" + ))) + .unwrap(); + let cookie = app + .clone() + .oneshot(login) + .await + .unwrap() + .headers() + .get("set-cookie") + .and_then(|c| c.to_str().ok()) + .expect("login should set a session cookie") + .to_string(); + + let request = Request::builder() + .uri(uri) + .method("POST") + .header("cookie", cookie) + .header("x-version", "1.0.0") + .body(Body::empty()) + .unwrap(); + app.oneshot(request).await.unwrap() +} + +async fn conflict_reason(response: axum::response::Response) -> String { + assert_eq!(response.status(), StatusCode::CONFLICT); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + body["details"]["reason"].as_str().unwrap().to_string() +} + +/// Whether any statement issued updates `table`'s `ical_sequence`. +fn bumped_sequence_of(db: DatabaseConnection, table: &str) -> bool { + let target = format!(r#"UPDATE "refactor_platform"."{table}""#); + db.into_transaction_log() + .iter() + .flat_map(|txn| txn.statements()) + .any(|stmt| stmt.sql.contains(&target) && stmt.sql.contains("ical_sequence")) +} + +/// The unfolded `.ics` attached to a Resend request, if any. +fn attached_ics(request: &mockito::Request) -> Option { + let payload: serde_json::Value = serde_json::from_slice(request.body().ok()?).ok()?; + let content = payload["attachments"][0]["content"].as_str()?; + let ics = String::from_utf8(STANDARD.decode(content).ok()?).ok()?; + Some(ics.replace("\r\n ", "").replace("\r\n\t", "")) +} + +/// Every `.ics` the Resend mock received, in order. +#[derive(Clone, Default)] +struct Received(Arc>>); + +impl Received { + fn recorder(&self) -> impl Fn(&mockito::Request) -> bool + Send + Sync + 'static { + let received = self.0.clone(); + move |request| { + received + .lock() + .unwrap() + .push(attached_ics(request).unwrap_or_default()); + true + } + } + + fn all(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +/// One recipient's send, told apart by the `to` address. +async fn mock_send_to( + server: &mut ServerGuard, + email: &str, + status: usize, + received: &Received, +) -> Mock { + server + .mock("POST", "/emails") + .match_body(mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#))) + .match_request(received.recorder()) + .with_status(status) + .with_body(r#"{"id":"email_test"}"#) + .expect(1) + .create_async() + .await +} + +/// The VEVENT carrying the RRULE. +fn master_vevent(ics: &str) -> &str { + ics.split("BEGIN:VEVENT") + .skip(1) + .find(|event| event.contains("RRULE")) + .expect("the snapshot has a master VEVENT") +} + +// ── Series ───────────────────────────────────────────────────────── + +#[tokio::test] +async fn series_resend_forbids_the_coachee_and_sends_nothing() { + let mut server = Server::new_async().await; + let no_send = server + .mock("POST", "/emails") + .expect(0) + .create_async() + .await; + let fixture = Fixture::new(); + let series = fixture.series(upcoming_start(), 3); + + let db = Arc::new( + fixture + .logged_in_as(&fixture.coachee) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![vec![fixture.relationship.clone()]]) + .into_connection(), + ); + + let response = post_resend( + db, + config(&server.url()), + COACHEE_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + no_send.assert_async().await; +} + +#[tokio::test] +async fn series_resend_refuses_a_series_with_nothing_upcoming() { + let server = Server::new_async().await; + let fixture = Fixture::new(); + let series = fixture.series(past_start(), 3); + let rows = fixture.members(&series); + + let db = Arc::new( + fixture + .logged_in_as(&fixture.coach) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![vec![fixture.relationship.clone()]]) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![rows]) + .into_connection(), + ); + + let response = post_resend( + db.clone(), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(conflict_reason(response).await, "no_upcoming_sessions"); + let db = Arc::try_unwrap(db).expect("the app has been dropped"); + assert!(!bumped_sequence_of(db, "coaching_session_series")); +} + +#[tokio::test] +async fn series_resend_refuses_a_legacy_series() { + let server = Server::new_async().await; + let fixture = Fixture::new(); + let series = fixture.series(upcoming_start(), 3); + let mut rows = fixture.members(&series); + rows[1].ical_recurrence_id = None; + + let db = Arc::new( + fixture + .logged_in_as(&fixture.coach) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![vec![fixture.relationship.clone()]]) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![rows]) + .into_connection(), + ); + + let response = post_resend( + db, + config(&server.url()), + COACH_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(conflict_reason(response).await, "legacy_series"); +} + +/// The series loads up to the send: the series twice (gate, then domain), its rows, the +/// bump, the parties, then the in-progress goals the description lists. +fn series_send_db( + fixture: &Fixture, + series: &coaching_session_series::Model, + rows: Vec, + bumped: &coaching_session_series::Model, +) -> Arc { + let db = fixture + .logged_in_as(&fixture.coach) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![vec![fixture.relationship.clone()]]) + .append_query_results(vec![vec![series.clone()]]) + .append_query_results(vec![rows]) + .append_query_results(vec![vec![bumped.clone()]]); + Arc::new( + fixture + .with_parties(db) + .append_query_results::(vec![vec![]]) + .into_connection(), + ) +} + +#[tokio::test] +async fn series_resend_sends_the_snapshot_under_the_next_sequence() { + let mut server = Server::new_async().await; + let fixture = Fixture::new(); + let series = fixture.series(upcoming_start(), 3); + let bumped = coaching_session_series::Model { + ical_sequence: 4, + ..series.clone() + }; + let rows = fixture.members(&series); + let received = Received::default(); + let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; + let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; + + let db = series_send_db(&fixture, &series, rows, &bumped); + let response = post_resend( + db.clone(), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + to_coachee.assert_async().await; + to_coach.assert_async().await; + let received = received.all(); + assert_eq!(received.len(), 2); + for ics in &received { + assert!(master_vevent(ics).contains("SEQUENCE:4"), "{ics}"); + } + let db = Arc::try_unwrap(db).expect("the app has been dropped"); + assert!(bumped_sequence_of(db, "coaching_session_series")); +} + +#[tokio::test] +async fn series_resend_reports_an_unreached_coachee() { + let mut server = Server::new_async().await; + let fixture = Fixture::new(); + let series = fixture.series(upcoming_start(), 3); + let bumped = coaching_session_series::Model { + ical_sequence: 4, + ..series.clone() + }; + let rows = fixture.members(&series); + let received = Received::default(); + let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 400, &received).await; + let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; + + let response = post_resend( + series_send_db(&fixture, &series, rows, &bumped), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + to_coachee.assert_async().await; + to_coach.assert_async().await; +} + +// ── Session ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn session_resend_forbids_the_coachee() { + let mut server = Server::new_async().await; + let no_send = server + .mock("POST", "/emails") + .expect(0) + .create_async() + .await; + let fixture = Fixture::new(); + let session = fixture.session(upcoming_start()); + + let db = Arc::new( + fixture + .logged_in_as(&fixture.coachee) + .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) + .into_connection(), + ); + + let response = post_resend( + db, + config(&server.url()), + COACHEE_EMAIL, + format!("/coaching_sessions/{}/resend_invite", session.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + no_send.assert_async().await; +} + +/// The gate's load, then the domain's. +fn session_refusal_db( + fixture: &Fixture, + session: &coaching_sessions::Model, +) -> Arc { + Arc::new( + fixture + .logged_in_as(&fixture.coach) + .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) + .append_query_results(vec![vec![session.clone()]]) + .into_connection(), + ) +} + +#[tokio::test] +async fn session_resend_refuses_a_current_series_member() { + let server = Server::new_async().await; + let fixture = Fixture::new(); + let session = coaching_sessions::Model { + coaching_session_series_id: Some(Id::new_v4()), + ical_recurrence_id: Some(upcoming_start()), + ..fixture.session(upcoming_start()) + }; + + let response = post_resend( + session_refusal_db(&fixture, &session), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_sessions/{}/resend_invite", session.id), + ) + .await; + + assert_eq!(conflict_reason(response).await, "series_member"); +} + +#[tokio::test] +async fn session_resend_refuses_a_past_one_off() { + let server = Server::new_async().await; + let fixture = Fixture::new(); + let session = fixture.session(past_start()); + + let response = post_resend( + session_refusal_db(&fixture, &session), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_sessions/{}/resend_invite", session.id), + ) + .await; + + assert_eq!(conflict_reason(response).await, "no_upcoming_sessions"); +} + +/// The session loads up to the send: the gate, the domain read, the bump, the parties, +/// then the topics, goals and open actions the description lists. +fn session_send_db( + fixture: &Fixture, + session: &coaching_sessions::Model, +) -> Arc { + let bumped = coaching_sessions::Model { + ical_sequence: session.ical_sequence + 1, + ..session.clone() + }; + let db = fixture + .logged_in_as(&fixture.coach) + .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) + .append_query_results(vec![vec![session.clone()]]) + .append_query_results(vec![vec![bumped]]); + Arc::new( + fixture + .with_parties(db) + .append_query_results::(vec![vec![]]) + .append_query_results::(vec![vec![]]) + .append_query_results::(vec![vec![]]) + .into_connection(), + ) +} + +#[tokio::test] +async fn session_resend_sends_a_legacy_member_standalone() { + let mut server = Server::new_async().await; + let fixture = Fixture::new(); + let session = coaching_sessions::Model { + coaching_session_series_id: Some(Id::new_v4()), + ical_recurrence_id: None, + ..fixture.session(upcoming_start()) + }; + let received = Received::default(); + let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; + let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; + + let response = post_resend( + session_send_db(&fixture, &session), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_sessions/{}/resend_invite", session.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + to_coachee.assert_async().await; + to_coach.assert_async().await; + let received = received.all(); + assert_eq!(received.len(), 2); + for ics in &received { + assert!( + ics.contains(&format!("UID:{}@myrefactor.com", session.id)), + "{ics}" + ); + assert!(!ics.contains("RECURRENCE-ID"), "{ics}"); + } +} + +#[tokio::test] +async fn session_resend_sends_a_one_off_under_the_next_sequence() { + let mut server = Server::new_async().await; + let fixture = Fixture::new(); + let session = fixture.session(upcoming_start()); + let received = Received::default(); + let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; + let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; + + let db = session_send_db(&fixture, &session); + let response = post_resend( + db.clone(), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_sessions/{}/resend_invite", session.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + to_coachee.assert_async().await; + to_coach.assert_async().await; + let db = Arc::try_unwrap(db).expect("the app has been dropped"); + assert!(bumped_sequence_of(db, "coaching_sessions")); +} diff --git a/web/src/protect/coaching_sessions.rs b/web/src/protect/coaching_sessions.rs index 5cd03b26e..a85167dd6 100644 --- a/web/src/protect/coaching_sessions.rs +++ b/web/src/protect/coaching_sessions.rs @@ -3,11 +3,11 @@ use axum::{ extract::{Path, Query, Request, State}, http::StatusCode, middleware::Next, - response::IntoResponse, + response::{IntoResponse, Response}, }; use serde::Deserialize; -use domain::{coaching_relationship, coaching_session, Id}; +use domain::{coaching_relationship, coaching_session, users, Id}; use log::{debug, error, warn}; #[derive(Debug, Deserialize)] @@ -55,35 +55,7 @@ pub(crate) async fn update( request: Request, next: Next, ) -> impl IntoResponse { - let (_coaching_session, coaching_relationship) = - match coaching_session::find_by_id_with_coaching_relationship( - app_state.db_conn_ref(), - coaching_session_id, - ) - .await - { - Ok(pair) => pair, - Err(e) => { - error!( - "Error resolving coaching session {coaching_session_id} for authorization: {e:?}" - ); - return crate::error::domain_error_into_response(e); - } - }; - - if coaching_relationship.coach_id == user.id && coaching_relationship.grants_access_to(&user) { - debug!( - "PUT auth passed: coaching_session_id={coaching_session_id} relationship_id={} user_id={}", - coaching_relationship.id, user.id - ); - next.run(request).await - } else { - warn!( - "PUT auth denied (not coach): coaching_session_id={coaching_session_id} relationship_id={} user_id={}", - coaching_relationship.id, user.id - ); - (StatusCode::FORBIDDEN, "FORBIDDEN").into_response() - } + require_coach(&app_state, &user, coaching_session_id, "PUT", request, next).await } /// Checks that coaching session record referenced by `coaching_session_id` @@ -98,6 +70,46 @@ pub(crate) async fn delete( request: Request, next: Next, ) -> impl IntoResponse { + require_coach( + &app_state, + &user, + coaching_session_id, + "DELETE", + request, + next, + ) + .await +} + +/// The coach-only gate `delete` applies, for `POST /coaching_sessions/:id/resend_invite`. +pub(crate) async fn resend_invite( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Path(coaching_session_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + require_coach( + &app_state, + &user, + coaching_session_id, + "POST resend_invite", + request, + next, + ) + .await +} + +/// Runs `next` only when `user` is the coach of the session's relationship and still has +/// access to it. `operation` labels the log lines. +async fn require_coach( + app_state: &AppState, + user: &users::Model, + coaching_session_id: Id, + operation: &str, + request: Request, + next: Next, +) -> Response { let (_coaching_session, coaching_relationship) = match coaching_session::find_by_id_with_coaching_relationship( app_state.db_conn_ref(), @@ -114,15 +126,15 @@ pub(crate) async fn delete( } }; - if coaching_relationship.coach_id == user.id && coaching_relationship.grants_access_to(&user) { + if coaching_relationship.coach_id == user.id && coaching_relationship.grants_access_to(user) { debug!( - "DELETE auth passed: coaching_session_id={coaching_session_id} relationship_id={} user_id={}", + "{operation} auth passed: coaching_session_id={coaching_session_id} relationship_id={} user_id={}", coaching_relationship.id, user.id ); next.run(request).await } else { warn!( - "DELETE auth denied (not coach): coaching_session_id={coaching_session_id} relationship_id={} user_id={}", + "{operation} auth denied (not coach): coaching_session_id={coaching_session_id} relationship_id={} user_id={}", coaching_relationship.id, user.id ); (StatusCode::FORBIDDEN, "FORBIDDEN").into_response() diff --git a/web/src/router.rs b/web/src/router.rs index 977308d9b..40ef83114 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -57,11 +57,13 @@ use utoipa_rapidoc::RapiDoc; coaching_session_controller::update, coaching_session_controller::update_title, coaching_session_controller::delete, + coaching_session_controller::resend_invite, coaching_session_series_controller::create, coaching_session_series_controller::read, coaching_session_series_controller::index, coaching_session_series_controller::update, coaching_session_series_controller::delete, + coaching_session_series_controller::resend_invite, coaching_session::meeting_recording_controller::create, coaching_session::meeting_recording_controller::read, coaching_session::meeting_recording_controller::delete, @@ -402,6 +404,18 @@ pub fn coaching_sessions_routes(app_state: AppState) -> Router { protect::coaching_sessions::delete, )), ) + .merge( + // POST /coaching_sessions/:id/resend_invite + Router::new() + .route( + "/coaching_sessions/:id/resend_invite", + post(coaching_session_controller::resend_invite), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::coaching_sessions::resend_invite, + )), + ) .route_layer(from_fn(require_auth)) .with_state(app_state) } @@ -429,6 +443,10 @@ pub fn coaching_session_series_routes(app_state: AppState) -> Router { "/coaching_session_series/:id", delete(coaching_session_series_controller::delete), ) + .route( + "/coaching_session_series/:id/resend_invite", + post(coaching_session_series_controller::resend_invite), + ) .route_layer(from_fn(require_auth)) .with_state(app_state) } From f6017cdb8cbefa6d0e9bc121bb91696aade6a8cd Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 11:25:03 -0500 Subject: [PATCH 05/11] feat: include each session's series summary in the user session list --- entity_api/src/coaching_session.rs | 211 ++++++++++++++++++ .../user/coaching_session_controller.rs | 3 +- web/src/params/user/coaching_session.rs | 10 + 3 files changed, 223 insertions(+), 1 deletion(-) diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 693a6adc0..18279eab4 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -782,6 +782,17 @@ pub struct EnrichedSession { pub display_title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub topics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub series: Option, +} + +/// The parts of a session's series the list view needs, without loading the series row. +#[derive(Debug, Clone, serde::Serialize, ToSchema)] +#[schema(as = domain::coaching_session::SeriesSummary)] +pub struct SeriesSummary { + pub id: Id, + /// Latest `date` across every session linked to the series. + pub last_session_date: NaiveDateTime, } /// Configuration for which related resources to include when fetching coaching sessions. @@ -825,6 +836,7 @@ pub struct IncludeOptions { pub goal: bool, pub agreements: bool, pub topics: bool, + pub series: bool, } impl IncludeOptions { @@ -839,6 +851,7 @@ impl IncludeOptions { goal: false, agreements: false, topics: false, + series: false, } } @@ -1022,6 +1035,7 @@ struct RelatedData { goals: HashMap>, agreements: HashMap, topics: HashMap>, + series_last_dates: HashMap, } /// Load all requested related data in efficient batches @@ -1078,6 +1092,17 @@ async fn load_related_data( data.topics = batch_load_topics(db, &session_ids).await?; } + // Load each series' latest session date by series_id + if includes.series { + let mut series_ids: Vec = sessions + .iter() + .filter_map(|s| s.coaching_session_series_id) + .collect(); + series_ids.sort_unstable(); + series_ids.dedup(); + data.series_last_dates = batch_load_series_summaries(db, &series_ids).await?; + } + Ok(data) } @@ -1231,6 +1256,35 @@ async fn batch_load_topics( Ok(map) } +#[derive(Debug, FromQueryResult)] +struct SeriesLastDate { + coaching_session_series_id: Id, + last_session_date: NaiveDateTime, +} + +/// Batch load the latest session date of each series (series_id -> MAX(date)). +async fn batch_load_series_summaries( + db: &impl ConnectionTrait, + series_ids: &[Id], +) -> Result, Error> { + if series_ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(Entity::find() + .select_only() + .column(Column::CoachingSessionSeriesId) + .column_as(Column::Date.max(), "last_session_date") + .filter(Column::CoachingSessionSeriesId.is_in(series_ids.iter().copied())) + .group_by(Column::CoachingSessionSeriesId) + .into_model::() + .all(db) + .await? + .into_iter() + .map(|r| (r.coaching_session_series_id, r.last_session_date)) + .collect()) +} + /// Assemble an enriched session from base session and related data. /// /// `includes` is needed to distinguish "not requested" (`None`) from @@ -1280,6 +1334,19 @@ fn assemble_enriched_session( None }; + let series = session + .coaching_session_series_id + .filter(|_| includes.series) + .and_then(|id| { + related + .series_last_dates + .get(&id) + .map(|&last_session_date| SeriesSummary { + id, + last_session_date, + }) + }); + EnrichedSession { session, relationship, @@ -1291,6 +1358,7 @@ fn assemble_enriched_session( viewer_last_viewed_at, display_title, topics, + series, } } @@ -2611,6 +2679,7 @@ mod tests { goal: false, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2623,6 +2692,7 @@ mod tests { goal: false, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_err()); } @@ -2635,6 +2705,7 @@ mod tests { goal: true, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2647,6 +2718,7 @@ mod tests { goal: true, agreements: true, topics: true, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2803,4 +2875,143 @@ mod tests { topic_select.sql ); } + + fn series_session(series_id: Option, date: NaiveDateTime) -> Model { + let now = chrono::Utc::now(); + Model { + id: Id::new_v4(), + coaching_relationship_id: Id::new_v4(), + coaching_session_series_id: series_id, + ical_sequence: 0, + ical_recurrence_id: None, + date, + collab_document_name: None, + duration_minutes: crate::duration::Duration::default_minutes(), + title: None, + meeting_url: None, + provider: None, + created_at: now.into(), + updated_at: now.into(), + hydrated_at: Some(now.into()), + notice_given_at: now.into(), + } + } + + fn naive(s: &str) -> NaiveDateTime { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap() + } + + #[tokio::test] + async fn include_series_attaches_last_session_date() -> Result<(), Error> { + let series_id = Id::new_v4(); + let d1 = naive("2026-11-02 15:00:00"); + let d2 = naive("2026-11-09 15:00:00"); + // The MAX the mock returns; deliberately later than d2 to prove it comes from the query. + let last = naive("2027-03-01 15:00:00"); + let first = series_session(Some(series_id), d1); + let second = series_session(Some(series_id), d2); + let one_off = series_session(None, d1); + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![first.clone(), second.clone(), one_off.clone()]]) + .append_query_results::, _>(vec![vec![]]) + .append_query_results(vec![vec![std::collections::BTreeMap::from([ + ( + "coaching_session_series_id".to_string(), + Value::from(series_id), + ), + ("last_session_date".to_string(), Value::from(last)), + ])]]) + .append_query_results(vec![Vec::::new()]) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let includes = IncludeOptions { + series: true, + ..IncludeOptions::none() + }; + let results = + find_by_user_with_includes(&db, Id::new_v4(), SessionQueryOptions::default(), includes) + .await?; + + assert_eq!(results.len(), 3); + for enriched in &results[..2] { + let series = enriched + .series + .as_ref() + .expect("series session has a summary"); + assert_eq!(series.id, series_id); + assert_eq!(series.last_session_date, last); + } + assert!(results[2].series.is_none()); + // Exactly one series query: sessions, views, series, then display-title topics and goals. + assert_eq!(db.into_transaction_log().len(), 5); + + Ok(()) + } + + #[tokio::test] + async fn include_series_off_by_default() -> Result<(), Error> { + let series_id = Id::new_v4(); + let session = series_session(Some(series_id), naive("2026-11-02 15:00:00")); + + // No series result is queued: a series query would consume the topics result and fail. + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![session.clone()]]) + .append_query_results::, _>(vec![vec![]]) + .append_query_results(vec![Vec::::new()]) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let results = find_by_user_with_includes( + &db, + Id::new_v4(), + SessionQueryOptions::default(), + IncludeOptions::default(), + ) + .await?; + + assert!(results.iter().all(|r| r.series.is_none())); + assert_eq!(db.into_transaction_log().len(), 4); + + Ok(()) + } + + #[test] + fn series_key_is_omitted_without_a_series_and_present_with_one() { + let series_id = Id::new_v4(); + let last = naive("2027-03-01 15:00:00"); + let related = RelatedData { + series_last_dates: HashMap::from([(series_id, last)]), + ..RelatedData::default() + }; + let includes = IncludeOptions { + series: true, + ..IncludeOptions::none() + }; + let enrich = |session| { + serde_json::to_value(assemble_enriched_session( + session, + &related, + includes, + &HashMap::new(), + &HashMap::new(), + )) + .unwrap() + }; + + let one_off = enrich(series_session(None, last)); + assert!(one_off.get("series").is_none()); + + let in_series = enrich(series_session(Some(series_id), last)); + assert_eq!( + in_series["series"], + serde_json::json!({ + "id": series_id, + "last_session_date": serde_json::to_value(last).unwrap(), + }) + ); + // Same wire format as the session's own `date`. + assert_eq!(in_series["series"]["last_session_date"], in_series["date"]); + } } diff --git a/web/src/controller/user/coaching_session_controller.rs b/web/src/controller/user/coaching_session_controller.rs index ee49e65a0..2dd0426a5 100644 --- a/web/src/controller/user/coaching_session_controller.rs +++ b/web/src/controller/user/coaching_session_controller.rs @@ -37,7 +37,7 @@ use utoipa::ToSchema; ("from_date" = Option, Query, description = "Filter by from_date (inclusive). Evaluated in `tz` if supplied; otherwise UTC."), ("to_date" = Option, Query, description = "Filter by to_date (inclusive at calendar-day precision). Evaluated in `tz` if supplied; otherwise UTC."), ("tz" = Option, Query, description = "Optional IANA timezone identifier (e.g. 'America/Los_Angeles'). Interprets `from_date`/`to_date` as local-day boundaries in that zone. Invalid value → 400 invalid_timezone."), - ("include" = Option, Query, description = "Comma-separated list of related resources to include. Valid values: 'relationship', 'organization', 'goal', 'agreements', 'topics'. Example: 'relationship,organization,goal'"), + ("include" = Option, Query, description = "Comma-separated list of related resources to include. Valid values: 'relationship', 'organization', 'goal', 'agreements', 'topics', 'series'. Example: 'relationship,organization,goal'"), ("sort_by" = Option, Query, description = "Sort by field. Valid values: 'date', 'created_at', 'updated_at'. Must be provided with sort_order.", example = "date"), ("sort_order" = Option, Query, description = "Sort order. Valid values: 'asc' (ascending), 'desc' (descending). Must be provided with sort_by.", example = "desc") ), @@ -88,6 +88,7 @@ pub async fn index( goal: params.include.contains(&IncludeParam::Goal), agreements: params.include.contains(&IncludeParam::Agreements), topics: params.include.contains(&IncludeParam::Topics), + series: params.include.contains(&IncludeParam::Series), }; let sort_column = params.get_sort_column(); let sort_order = params.get_sort_order(); diff --git a/web/src/params/user/coaching_session.rs b/web/src/params/user/coaching_session.rs index 122c6122d..9a87f96f1 100644 --- a/web/src/params/user/coaching_session.rs +++ b/web/src/params/user/coaching_session.rs @@ -27,6 +27,8 @@ pub enum IncludeParam { Agreements, /// Include session topics Topics, + /// Include the session's series id and its latest session date + Series, } /// Query parameters for GET `/users/{user_id}/coaching_sessions` endpoint. @@ -196,4 +198,12 @@ mod tests { assert!(serde_json::from_str::(r#""MONTH""#).is_err()); assert!(serde_json::from_str::(r#""""#).is_err()); } + + #[test] + fn include_param_parses_series() { + let uri: axum::http::Uri = "/?include=series".parse().unwrap(); + let axum::extract::Query(params) = + axum::extract::Query::::try_from_uri(&uri).expect("include=series parses"); + assert_eq!(params.include, vec![IncludeParam::Series]); + } } From a3727b7f236b2291527d3aa3eb760df9403774f2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 11:27:10 -0500 Subject: [PATCH 06/11] docs: mark resend invite backend phases done --- docs/implementation-plans/resend-calendar-invite.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md index bdc8a3a43..542b71e90 100644 --- a/docs/implementation-plans/resend-calendar-invite.md +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -33,11 +33,13 @@ Both POSTs take no body. A 409 body carries a machine-readable reason (`legacy_s ## Phases -- [ ] B1: `ical.rs` gains EXDATE and multi-VEVENT support; a pure `series_snapshot` builder -- [ ] B2: `send_series_invite_email` returns `Delivered` -- [ ] B3: both `resend_invite` endpoints -- [ ] B4: `include=series` with `last_session_date` +- [x] B1 (`f46499a3`): `ical.rs` gains EXDATE and multi-VEVENT support; a pure `series_snapshot` builder +- [x] B2 (`6ba8f08f`): `send_series_invite_email` returns `Delivered` +- [x] B3 (`ec624919`): both `resend_invite` endpoints +- [x] B4 (`f6017cdb`): `include=series` with `last_session_date` ## Follow-ups +- A resend that fails before any send (missing API key or template id) returns 500, not 502. Only a send the provider refuses maps to 502. The frontend shows the same toast for both. + - EXDATE and RECURRENCE-ID values are rendered in the coach's zone from UTC slots. If a series spans a DST change, a client that expands the RRULE in local time can place a slot one hour away from our stored UTC slot. This was already true for the existing RECURRENCE-ID path; it isn't new here. From ff05caa40b20bf91cf2b9bab5d19fc5db9fde7a8 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:12:01 -0500 Subject: [PATCH 07/11] fix(ical): keep series overrides at or above the master SEQUENCE --- domain/src/coaching_session_series.rs | 16 ++++- domain/src/emails.rs | 3 +- domain/src/emails_tests.rs | 33 ++++++++++ entity_api/src/coaching_session.rs | 74 ++++++++++++++++++++++- web/src/controller/resend_invite_tests.rs | 68 ++++++++++++++++++++- 5 files changed, 187 insertions(+), 7 deletions(-) diff --git a/domain/src/coaching_session_series.rs b/domain/src/coaching_session_series.rs index 31c29aa97..88cf3a246 100644 --- a/domain/src/coaching_session_series.rs +++ b/domain/src/coaching_session_series.rs @@ -362,7 +362,21 @@ pub async fn resend_invite( } // Committed before the send: a failed send leaves a gap in SEQUENCE, which is harmless. - let bumped = coaching_session_series::increment_ical_sequence(db, series.id).await?; + // Members are raised with the series because Google drops an override whose SEQUENCE + // trails its master. + let txn = db.begin().await.map_err(entity_api::error::Error::from)?; + let bumped = coaching_session_series::increment_ical_sequence(&txn, series.id).await?; + let rows = entity_api::coaching_session::raise_series_ical_sequence( + &txn, + series.id, + bumped.ical_sequence, + ) + .await?; + txn.commit().await.map_err(entity_api::error::Error::from)?; + + let mut upcoming: Vec = + rows.iter().filter(|s| s.date > now).cloned().collect(); + upcoming.sort_by_key(|s| s.date); let parties = emails::InviteParties::load(db, bumped.coaching_relationship_id).await?; let delivered = emails::send_series_invite_resend( db, diff --git a/domain/src/emails.rs b/domain/src/emails.rs index eff3ec4de..edc87bfc5 100644 --- a/domain/src/emails.rs +++ b/domain/src/emails.rs @@ -1683,7 +1683,8 @@ fn build_series_snapshot_ics( member .filter(|m| m.date != *slot || m.duration_minutes != rule.duration_minutes) .map(|m| ical::IcsInvite { - sequence: m.ical_sequence, + // Google drops an override whose SEQUENCE trails its master. + sequence: m.ical_sequence.max(series.ical_sequence), start: m.date, duration_minutes: m.duration_minutes, location_url: m.meeting_url.clone(), diff --git a/domain/src/emails_tests.rs b/domain/src/emails_tests.rs index ee67165aa..0e11badc4 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -2556,6 +2556,39 @@ fn snapshot_moved_slot_becomes_override() { assert!(events[0].contains("SEQUENCE:1")); } +#[test] +fn snapshot_override_sequence_never_trails_master() { + let series = coaching_session_series::Model { + ical_sequence: 3, + ..snapshot_series(snapshot_start()) + }; + let mut members = snapshot_members(&series); + members[3].date += chrono::Duration::days(1); + members[3].ical_sequence = 1; + let ics = snapshot(&series, &members); + + let events = vevents(&ics); + assert_eq!(events.len(), 2); + assert!(events[0].contains("SEQUENCE:3")); + assert!(events[1].contains("SEQUENCE:3")); +} + +#[test] +fn snapshot_override_keeps_a_higher_member_sequence() { + let series = coaching_session_series::Model { + ical_sequence: 3, + ..snapshot_series(snapshot_start()) + }; + let mut members = snapshot_members(&series); + members[3].date += chrono::Duration::days(1); + members[3].ical_sequence = 5; + let ics = snapshot(&series, &members); + + let events = vevents(&ics); + assert_eq!(events.len(), 2); + assert!(events[1].contains("SEQUENCE:5")); +} + #[test] fn snapshot_deleted_and_moved_together() { let series = snapshot_series(snapshot_start()); diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 18279eab4..831bf72fd 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -12,9 +12,11 @@ use entity::{ }; use log::debug; use sea_orm::{ - entity::prelude::*, sea_query::Expr, ActiveValue::Unchanged, ConnectionTrait, DatabaseBackend, - DatabaseConnection, ExprTrait, FromQueryResult, JoinType, Order, QueryOrder, QuerySelect, - QueryTrait, Select, Set, Statement, TryIntoModel, Value, + entity::prelude::*, + sea_query::{Expr, Func}, + ActiveValue::Unchanged, + ConnectionTrait, DatabaseBackend, DatabaseConnection, ExprTrait, FromQueryResult, JoinType, + Order, QueryOrder, QuerySelect, QueryTrait, Select, Set, Statement, TryIntoModel, Value, }; use serde::Serialize; use std::collections::HashMap; @@ -622,6 +624,25 @@ pub async fn increment_ical_sequence(db: &impl ConnectionTrait, id: Id) -> Resul }) } +/// Raise every session in a series to at least `floor`. +/// +/// Google drops an override whose SEQUENCE trails its master, so a series bump +/// must lift its members with it. +pub async fn raise_series_ical_sequence( + db: &impl ConnectionTrait, + series_id: Id, + floor: i32, +) -> Result, Error> { + Ok(Entity::update_many() + .col_expr( + Column::IcalSequence, + Func::greatest([Expr::col(Column::IcalSequence), Expr::val(floor)]).into(), + ) + .filter(Column::CoachingSessionSeriesId.eq(series_id)) + .exec_with_returning(db) + .await?) +} + /// Find the most recent meeting URL for a coaching relationship and provider. /// /// Searches all sessions in the given relationship for one that has a `meeting_url` @@ -1915,6 +1936,53 @@ mod tests { Ok(()) } + #[tokio::test] + async fn raise_series_ical_sequence_uses_greatest() -> Result<(), Error> { + let now = chrono::Utc::now(); + let series_id = Id::new_v4(); + let raised = Model { + id: Id::new_v4(), + coaching_relationship_id: Id::new_v4(), + coaching_session_series_id: Some(series_id), + ical_sequence: 4, + ical_recurrence_id: None, + collab_document_name: None, + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 1) + .unwrap() + .and_hms_opt(10, 0, 0) + .unwrap(), + duration_minutes: crate::duration::Duration::default_minutes(), + title: None, + meeting_url: None, + provider: None, + created_at: now.into(), + updated_at: now.into(), + hydrated_at: None, + notice_given_at: now.into(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![raised.clone()]]) + .into_connection(); + + let result = raise_series_ical_sequence(&db, series_id, 4).await?; + assert_eq!(result, vec![raised]); + + let statements: Vec = db + .into_transaction_log() + .iter() + .flat_map(|txn| txn.statements()) + .map(|stmt| stmt.sql.clone()) + .collect(); + assert_eq!(statements.len(), 1, "{statements:?}"); + let sql = &statements[0]; + assert!(sql.starts_with("UPDATE"), "{sql}"); + assert!(sql.contains(r#""coaching_sessions""#), "{sql}"); + assert!(sql.contains("GREATEST"), "{sql}"); + assert!(sql.contains(r#""coaching_session_series_id" = "#), "{sql}"); + Ok(()) + } + #[tokio::test] async fn find_by_id_returns_a_single_record() -> Result<(), Error> { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); diff --git a/web/src/controller/resend_invite_tests.rs b/web/src/controller/resend_invite_tests.rs index 08ac2ee95..0df942419 100644 --- a/web/src/controller/resend_invite_tests.rs +++ b/web/src/controller/resend_invite_tests.rs @@ -425,20 +425,29 @@ async fn series_resend_refuses_a_legacy_series() { } /// The series loads up to the send: the series twice (gate, then domain), its rows, the -/// bump, the parties, then the in-progress goals the description lists. +/// bump, the rows raised to the bump, the parties, then the in-progress goals the +/// description lists. fn series_send_db( fixture: &Fixture, series: &coaching_session_series::Model, rows: Vec, bumped: &coaching_session_series::Model, ) -> Arc { + let raised: Vec = rows + .iter() + .map(|row| coaching_sessions::Model { + ical_sequence: row.ical_sequence.max(bumped.ical_sequence), + ..row.clone() + }) + .collect(); let db = fixture .logged_in_as(&fixture.coach) .append_query_results(vec![vec![series.clone()]]) .append_query_results(vec![vec![fixture.relationship.clone()]]) .append_query_results(vec![vec![series.clone()]]) .append_query_results(vec![rows]) - .append_query_results(vec![vec![bumped.clone()]]); + .append_query_results(vec![vec![bumped.clone()]]) + .append_query_results(vec![raised]); Arc::new( fixture .with_parties(db) @@ -482,6 +491,61 @@ async fn series_resend_sends_the_snapshot_under_the_next_sequence() { assert!(bumped_sequence_of(db, "coaching_session_series")); } +/// Google drops an override whose SEQUENCE trails its master, so members rise with the bump. +#[tokio::test] +async fn series_resend_raises_moved_members_to_the_master_sequence() { + let mut server = Server::new_async().await; + let fixture = Fixture::new(); + let series = fixture.series(upcoming_start(), 3); + let bumped = coaching_session_series::Model { + ical_sequence: 4, + ..series.clone() + }; + let mut rows = fixture.members(&series); + rows[1].date += chrono::Duration::days(1); + rows[1].ical_sequence = 1; + let received = Received::default(); + let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; + let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; + + let db = series_send_db(&fixture, &series, rows, &bumped); + let response = post_resend( + db.clone(), + config(&server.url()), + COACH_EMAIL, + format!("/coaching_session_series/{}/resend_invite", series.id), + ) + .await; + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + to_coachee.assert_async().await; + to_coach.assert_async().await; + for ics in &received.all() { + let master = master_vevent(ics); + let moved = ics + .split("BEGIN:VEVENT") + .skip(1) + .find(|event| event.contains("RECURRENCE-ID")) + .expect("the snapshot has an override VEVENT"); + assert!(master.contains("SEQUENCE:4"), "{ics}"); + assert!(moved.contains("SEQUENCE:4"), "{ics}"); + } + let db = Arc::try_unwrap(db).expect("the app has been dropped"); + let raised_members = db + .into_transaction_log() + .iter() + .flat_map(|txn| txn.statements()) + .any(|stmt| { + stmt.sql + .contains(r#"UPDATE "refactor_platform"."coaching_sessions""#) + && stmt.sql.contains("GREATEST") + }); + assert!( + raised_members, + "members must be raised to the series SEQUENCE" + ); +} + #[tokio::test] async fn series_resend_reports_an_unreached_coachee() { let mut server = Server::new_async().await; From 7dcd1c2de2447b5e8a8788f20f663f37fca495b2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:22:13 -0500 Subject: [PATCH 08/11] docs: record B5 and pre-existing calendar follow-ups --- docs/implementation-plans/resend-calendar-invite.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md index 542b71e90..e912c099a 100644 --- a/docs/implementation-plans/resend-calendar-invite.md +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -28,7 +28,7 @@ Both POSTs take no body. A 409 body carries a machine-readable reason (`legacy_s - Slots = `expand_recurrence(rule.start_at, &rule.recurrence)`, each truncated with `SessionDate`, exactly as `bulk_create_recurring` stores them in `ical_recurrence_id`. - Members = sessions whose `coaching_session_series_id` is this series and whose `ical_recurrence_id` is one of the slots. A member whose recurrence id is not a slot (for example a past session left over from before a series reschedule) is ignored. - Master: DTSTART = `rule.start_at`, duration = `rule.duration_minutes`, RRULE from `rule.recurrence`, SEQUENCE = `series.ical_sequence`, and EXDATE for every slot that has no member. -- Overrides: one per member where `date != ical_recurrence_id` or `duration_minutes != rule.duration_minutes`. RECURRENCE-ID is the slot, DTSTART is `date`, SEQUENCE is the session's `ical_sequence`. +- Overrides: one per member where `date != ical_recurrence_id` or `duration_minutes != rule.duration_minutes`. RECURRENCE-ID is the slot, DTSTART is `date`, and SEQUENCE is `max(session.ical_sequence, series.ical_sequence)`. The resend first raises every member to the bumped series value in the same transaction. - Everything goes in one VCALENDAR with `METHOD:REQUEST` and a single VTIMEZONE block. ## Phases @@ -37,9 +37,14 @@ Both POSTs take no body. A 409 body carries a machine-readable reason (`legacy_s - [x] B2 (`6ba8f08f`): `send_series_invite_email` returns `Delivered` - [x] B3 (`ec624919`): both `resend_invite` endpoints - [x] B4 (`f6017cdb`): `include=series` with `last_session_date` +- [x] B5 (`ff05caa4`): a series resend raises every member's `ical_sequence` to at least the series' value, and overrides never trail the master. Google Calendar drops an override whose SEQUENCE is lower than its master's; this was found in the live run. ## Follow-ups +- Series dates are spread out in UTC, and the `.ics` is anchored to the coach's profile zone. For a coach in a DST zone, the calendar keeps the local wall time across DST while the app keeps the UTC time, so the two drift by an hour. This predates this feature. +- After two or more series reschedules, recreated sessions start at `ical_sequence` 0 while the series is at 2 or more. A later single-occurrence reschedule can then send an override below its master, which Google drops. A resend now repairs this, but reschedule should seed new sessions at the series value. This predates this feature. +- The "recurring sessions scheduled" email tells the coach that the coachee scheduled the series (`other_user_role`). This predates this feature; the resend inherits it. + - A resend that fails before any send (missing API key or template id) returns 500, not 502. Only a send the provider refuses maps to 502. The frontend shows the same toast for both. - EXDATE and RECURRENCE-ID values are rendered in the coach's zone from UTC slots. If a series spans a DST change, a client that expands the RRULE in local time can place a slot one hour away from our stored UTC slot. This was already true for the existing RECURRENCE-ID path; it isn't new here. From 0d80d49d5922d406444e95182d2b8413c666cfcc Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:27:35 -0500 Subject: [PATCH 09/11] docs: drop the resend invite implementation plan from the branch --- .../resend-calendar-invite.md | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 docs/implementation-plans/resend-calendar-invite.md diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md deleted file mode 100644 index e912c099a..000000000 --- a/docs/implementation-plans/resend-calendar-invite.md +++ /dev/null @@ -1,50 +0,0 @@ -# Resend calendar invite (backend) - -Lets a coach resend the calendar invite for a coaching session series or a one-off session, so a coachee who missed or declined part of a series can get the whole series back on their calendar. The frontend counterpart is `refactor-platform-fe/docs/implementation-plans/resend-calendar-invite.md`. - -Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one phase per commit. - -## Decisions - -- Recipients are coach and coachee, the same as the original invite. The resend uses the existing "scheduled" templates (`RecurringSessionsScheduled`, `SessionScheduled`), so no new templates or config are needed. -- Every resend bumps `ical_sequence` before it sends. The UID stays the same, so calendars update the existing event rather than adding a copy. -- A series resend carries a **snapshot**: the master VEVENT (RRULE plus an EXDATE for every slot with no member), plus one override VEVENT per member that was moved or resized. -- There is no server-side throttle. The endpoints are coach-only and the frontend puts them behind a confirm dialog. -- Legacy series (members with a NULL `ical_recurrence_id`) get a 409 from the series endpoint. The session endpoint accepts their members, which already go out standalone through `build_session_ics`. -- The send is synchronous. The endpoint answers 502 when the coachee wasn't reached. - -## Contract - -| Route | Auth | Responses | -|---|---|---| -| `POST /coaching_session_series/:id/resend_invite` | `CoachingSessionSeriesCoachAccess` | 204; 409 when the series is legacy or has no upcoming session; 502 when the coachee wasn't reached | -| `POST /coaching_sessions/:id/resend_invite` | coach of the session's relationship | 204; 409 when the session is a member of a non-legacy series; 502 when the coachee wasn't reached | -| `GET /users/:user_id/coaching_sessions?include=series` | existing | each series member carries `series: { id, last_session_date }`; one-offs have no `series` key | - -Both POSTs take no body. A 409 body carries a machine-readable reason (`legacy_series` / `no_upcoming_sessions` / `series_member`) so the frontend can choose the right toast. - -## Snapshot rules (B1) - -- Slots = `expand_recurrence(rule.start_at, &rule.recurrence)`, each truncated with `SessionDate`, exactly as `bulk_create_recurring` stores them in `ical_recurrence_id`. -- Members = sessions whose `coaching_session_series_id` is this series and whose `ical_recurrence_id` is one of the slots. A member whose recurrence id is not a slot (for example a past session left over from before a series reschedule) is ignored. -- Master: DTSTART = `rule.start_at`, duration = `rule.duration_minutes`, RRULE from `rule.recurrence`, SEQUENCE = `series.ical_sequence`, and EXDATE for every slot that has no member. -- Overrides: one per member where `date != ical_recurrence_id` or `duration_minutes != rule.duration_minutes`. RECURRENCE-ID is the slot, DTSTART is `date`, and SEQUENCE is `max(session.ical_sequence, series.ical_sequence)`. The resend first raises every member to the bumped series value in the same transaction. -- Everything goes in one VCALENDAR with `METHOD:REQUEST` and a single VTIMEZONE block. - -## Phases - -- [x] B1 (`f46499a3`): `ical.rs` gains EXDATE and multi-VEVENT support; a pure `series_snapshot` builder -- [x] B2 (`6ba8f08f`): `send_series_invite_email` returns `Delivered` -- [x] B3 (`ec624919`): both `resend_invite` endpoints -- [x] B4 (`f6017cdb`): `include=series` with `last_session_date` -- [x] B5 (`ff05caa4`): a series resend raises every member's `ical_sequence` to at least the series' value, and overrides never trail the master. Google Calendar drops an override whose SEQUENCE is lower than its master's; this was found in the live run. - -## Follow-ups - -- Series dates are spread out in UTC, and the `.ics` is anchored to the coach's profile zone. For a coach in a DST zone, the calendar keeps the local wall time across DST while the app keeps the UTC time, so the two drift by an hour. This predates this feature. -- After two or more series reschedules, recreated sessions start at `ical_sequence` 0 while the series is at 2 or more. A later single-occurrence reschedule can then send an override below its master, which Google drops. A resend now repairs this, but reschedule should seed new sessions at the series value. This predates this feature. -- The "recurring sessions scheduled" email tells the coach that the coachee scheduled the series (`other_user_role`). This predates this feature; the resend inherits it. - -- A resend that fails before any send (missing API key or template id) returns 500, not 502. Only a send the provider refuses maps to 502. The frontend shows the same toast for both. - -- EXDATE and RECURRENCE-ID values are rendered in the coach's zone from UTC slots. If a series spans a DST change, a client that expands the RRULE in local time can place a slot one hour away from our stored UTC slot. This was already true for the existing RECURRENCE-ID path; it isn't new here. From 93478ed678101ccc0a4d6da83cca4fc5019f2561 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:47:39 -0500 Subject: [PATCH 10/11] test: tighten the resend invite tests --- domain/src/emails_tests.rs | 443 ++++------- domain/src/gateway/ical_tests.rs | 78 +- entity_api/src/coaching_session.rs | 19 +- web/src/controller/resend_invite_tests.rs | 895 ++++++++-------------- 4 files changed, 517 insertions(+), 918 deletions(-) diff --git a/domain/src/emails_tests.rs b/domain/src/emails_tests.rs index 0e11badc4..8cfb82216 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -2447,38 +2447,33 @@ fn test_build_series_invite_ics_carries_bumped_sequence() { // ── Series Snapshot Tests ────────────────────────────────────────── /// A weekly, six-occurrence series starting at `start_at` (a Tuesday, UTC). -fn snapshot_series(start_at: NaiveDateTime) -> coaching_session_series::Model { +fn snapshot_series(start_at: NaiveDateTime, ical_sequence: i32) -> coaching_session_series::Model { coaching_session_series::Model { rule: serde_json::json!({ "start_at": start_at, "recurrence": { "frequency": "weekly", "interval": 1, "count": 6 }, "duration_minutes": 60, }), - ical_sequence: 1, + ical_sequence, ..create_test_series() } } -/// Tuesday Sep 15 2026, 19:00 UTC: 14:00 in Chicago (CDT) for all six weeks. -fn snapshot_start() -> NaiveDateTime { - NaiveDate::from_ymd_opt(2026, 9, 15) - .unwrap() - .and_hms_opt(19, 0, 0) - .unwrap() +fn at(year: i32, month: u32, day: u32, hour: u32) -> NaiveDateTime { + let date = NaiveDate::from_ymd_opt(year, month, day).unwrap(); + date.and_hms_opt(hour, 0, 0).unwrap() } -/// The six weekly slots from `start_at`. -fn snapshot_slots(start_at: NaiveDateTime) -> Vec { - (0..6) - .map(|week| start_at + chrono::Duration::weeks(week)) - .collect() +/// Tuesday Sep 15 2026, 19:00 UTC: 14:00 in Chicago (CDT) for all six weeks. +fn snapshot_start() -> NaiveDateTime { + at(2026, 9, 15, 19) } /// One unmoved member per slot. fn snapshot_members(series: &coaching_session_series::Model) -> Vec { let rule: SeriesRule = serde_json::from_value(series.rule.clone()).unwrap(); - snapshot_slots(rule.start_at) - .into_iter() + (0..6) + .map(|week| rule.start_at + chrono::Duration::weeks(week)) .map(|slot| coaching_sessions::Model { duration_minutes: 60, meeting_url: Some("https://meet.example/series".into()), @@ -2487,6 +2482,24 @@ fn snapshot_members(series: &coaching_session_series::Model) -> Vec Vec { + let mut members = snapshot_members(series); + for &(slot, sequence) in moved { + members[slot].date += chrono::Duration::days(1); + members[slot].ical_sequence = sequence; + } + deleted.iter().rev().for_each(|&slot| { + members.remove(slot); + }); + members +} + /// The unfolded snapshot for a Chicago coach. fn snapshot( series: &coaching_session_series::Model, @@ -2494,172 +2507,127 @@ fn snapshot( ) -> String { let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); - let dtstamp = NaiveDate::from_ymd_opt(2026, 9, 1) - .unwrap() - .and_hms_opt(12, 0, 0) - .unwrap(); + let dtstamp = at(2026, 9, 1, 12); let ics = build_series_snapshot_ics(&coach, &coachee, series, members, "desc".into(), dtstamp) .unwrap(); unfold(&ics) } -/// Every VEVENT span, in order. -fn vevents(ics: &str) -> Vec<&str> { - ics.match_indices("BEGIN:VEVENT") - .map(|(start, _)| { - let end = start + ics[start..].find("END:VEVENT").unwrap(); - &ics[start..end] - }) - .collect() +/// The snapshot of the series from `start` at `sequence`, edited as [`edit_members`] does. +fn edited_snapshot( + start: NaiveDateTime, + sequence: i32, + deleted: &[usize], + moved: &[(usize, i32)], +) -> String { + let series = snapshot_series(start, sequence); + snapshot(&series, &edit_members(&series, deleted, moved)) +} + +/// Asserts the master (Chicago local `dtstart`, `sequence`), exactly `exdates`, then one +/// override per `(recurrence_id, dtstart, sequence)`, in order. +fn assert_snapshot( + ics: &str, + (dtstart, sequence): (&str, i32), + exdates: &[&str], + overrides: &[(&str, &str, i32)], +) { + let tz = "TZID=America/Chicago"; + let events: Vec<&str> = ics + .match_indices("BEGIN:VEVENT") + .map(|(start, _)| &ics[start..start + ics[start..].find("END:VEVENT").unwrap()]) + .collect(); + assert_eq!(events.len(), 1 + overrides.len(), "{ics}"); + let master = events[0]; + assert!(master.contains(&format!("DTSTART;{tz}:{dtstart}")), "{ics}"); + assert!(master.contains("RRULE:FREQ=WEEKLY;COUNT=6"), "{ics}"); + assert!(master.contains(&format!("SEQUENCE:{sequence}")), "{ics}"); + assert!(master.contains("LOCATION:https://meet.example/series")); + assert_eq!(ics.matches("EXDATE").count(), exdates.len(), "{ics}"); + for exdate in exdates { + assert!(ics.contains(&format!("EXDATE;{tz}:{exdate}")), "{ics}"); + } + for (event, (recurrence_id, dtstart, sequence)) in events[1..].iter().zip(overrides) { + assert!(event.contains(&format!("RECURRENCE-ID;{tz}:{recurrence_id}"))); + assert!( + event.contains(&format!("DTSTART;{tz}:{dtstart}")), + "{event}" + ); + assert!(event.contains(&format!("SEQUENCE:{sequence}")), "{event}"); + assert!(!event.contains("RRULE"), "{event}"); + } } +const FIRST: &str = "20260915T140000"; + #[test] fn snapshot_untouched_series_is_master_only() { - let series = snapshot_series(snapshot_start()); - let ics = snapshot(&series, &snapshot_members(&series)); - - let events = vevents(&ics); - assert_eq!(events.len(), 1); - assert_eq!(ics.matches("EXDATE").count(), 0); - assert!(events[0].contains("RRULE:FREQ=WEEKLY;COUNT=6")); - assert!(events[0].contains("SEQUENCE:1")); - assert!(events[0].contains("LOCATION:https://meet.example/series")); + let ics = edited_snapshot(snapshot_start(), 1, &[], &[]); + assert_snapshot(&ics, (FIRST, 1), &[], &[]); } #[test] fn snapshot_deleted_slot_becomes_exdate() { - let series = snapshot_series(snapshot_start()); - let mut members = snapshot_members(&series); - members.remove(3); - let ics = snapshot(&series, &members); - - assert_eq!(ics.matches("EXDATE").count(), 1); - assert!(ics.contains("EXDATE;TZID=America/Chicago:20261006T140000")); - assert_eq!(vevents(&ics).len(), 1); + let ics = edited_snapshot(snapshot_start(), 1, &[3], &[]); + assert_snapshot(&ics, (FIRST, 1), &["20261006T140000"], &[]); } #[test] fn snapshot_moved_slot_becomes_override() { - let series = snapshot_series(snapshot_start()); - let mut members = snapshot_members(&series); - members[2].date += chrono::Duration::days(1); - members[2].ical_sequence = 4; - let ics = snapshot(&series, &members); - - let events = vevents(&ics); - assert_eq!(events.len(), 2); - let moved = events[1]; - assert!(moved.contains("RECURRENCE-ID;TZID=America/Chicago:20260929T140000")); - assert!(moved.contains("DTSTART;TZID=America/Chicago:20260930T140000")); - assert!(moved.contains("SEQUENCE:4")); - assert!(!moved.contains("RRULE")); - assert!(events[0].contains("SEQUENCE:1")); + let ics = edited_snapshot(snapshot_start(), 1, &[], &[(2, 4)]); + let moved = ("20260929T140000", "20260930T140000", 4); + assert_snapshot(&ics, (FIRST, 1), &[], &[moved]); } #[test] -fn snapshot_override_sequence_never_trails_master() { - let series = coaching_session_series::Model { - ical_sequence: 3, - ..snapshot_series(snapshot_start()) - }; - let mut members = snapshot_members(&series); - members[3].date += chrono::Duration::days(1); - members[3].ical_sequence = 1; - let ics = snapshot(&series, &members); - - let events = vevents(&ics); - assert_eq!(events.len(), 2); - assert!(events[0].contains("SEQUENCE:3")); - assert!(events[1].contains("SEQUENCE:3")); +fn snapshot_deleted_and_moved_together() { + let ics = edited_snapshot(snapshot_start(), 1, &[3], &[(2, 1)]); + let moved = ("20260929T140000", "20260930T140000", 1); + assert_snapshot(&ics, (FIRST, 1), &["20261006T140000"], &[moved]); } +/// Nothing is filtered by `now`: a moved past occurrence still needs its override. #[test] -fn snapshot_override_keeps_a_higher_member_sequence() { - let series = coaching_session_series::Model { - ical_sequence: 3, - ..snapshot_series(snapshot_start()) - }; - let mut members = snapshot_members(&series); - members[3].date += chrono::Duration::days(1); - members[3].ical_sequence = 5; - let ics = snapshot(&series, &members); - - let events = vevents(&ics); - assert_eq!(events.len(), 2); - assert!(events[1].contains("SEQUENCE:5")); +fn snapshot_past_moved_member_kept() { + // Tuesday Jan 7 2025, 19:00 UTC: 13:00 in Chicago (CST). + let ics = edited_snapshot(at(2025, 1, 7, 19), 1, &[], &[(0, 1)]); + let moved = ("20250107T130000", "20250108T130000", 1); + assert_snapshot(&ics, ("20250107T130000", 1), &[], &[moved]); } #[test] -fn snapshot_deleted_and_moved_together() { - let series = snapshot_series(snapshot_start()); - let mut members = snapshot_members(&series); - members[2].date += chrono::Duration::days(1); - members.remove(3); - let ics = snapshot(&series, &members); - - assert_eq!(ics.matches("EXDATE").count(), 1); - assert!(ics.contains("EXDATE;TZID=America/Chicago:20261006T140000")); - assert_eq!(vevents(&ics).len(), 2); +fn snapshot_master_dtstart_is_rule_start_even_if_first_moved() { + let ics = edited_snapshot(snapshot_start(), 1, &[], &[(0, 1)]); + assert_snapshot(&ics, (FIRST, 1), &[], &[(FIRST, "20260916T140000", 1)]); } -/// Nothing is filtered by `now`: a moved past occurrence still needs its override. +/// Google drops an override whose SEQUENCE trails its master; a higher one is kept. #[test] -fn snapshot_past_moved_member_kept() { - // Tuesday Jan 7 2025, 19:00 UTC: 13:00 in Chicago (CST). - let start = NaiveDate::from_ymd_opt(2025, 1, 7) - .unwrap() - .and_hms_opt(19, 0, 0) - .unwrap(); - let series = snapshot_series(start); - let mut members = snapshot_members(&series); - members[0].date += chrono::Duration::days(1); - let ics = snapshot(&series, &members); - - let events = vevents(&ics); - assert_eq!(events.len(), 2); - assert!(events[1].contains("RECURRENCE-ID;TZID=America/Chicago:20250107T130000")); - assert!(events[1].contains("DTSTART;TZID=America/Chicago:20250108T130000")); +fn snapshot_override_sequence_is_at_least_the_master() { + let trailing = edited_snapshot(snapshot_start(), 3, &[], &[(3, 1)]); + let leading = edited_snapshot(snapshot_start(), 3, &[], &[(3, 5)]); + let moved = |sequence| ("20261006T140000", "20261007T140000", sequence); + assert_snapshot(&trailing, (FIRST, 3), &[], &[moved(3)]); + assert_snapshot(&leading, (FIRST, 3), &[], &[moved(5)]); } #[test] fn snapshot_ignores_non_slot_and_null_members() { - let series = snapshot_series(snapshot_start()); + let series = snapshot_series(snapshot_start(), 1); let members = snapshot_members(&series); - let baseline = snapshot(&series, &members); - - let early = NaiveDate::from_ymd_opt(2026, 9, 1) - .unwrap() - .and_hms_opt(19, 0, 0) - .unwrap(); let stray = |recurrence_id| coaching_sessions::Model { coaching_session_series_id: Some(series.id), ical_recurrence_id: recurrence_id, - date: early, + date: snapshot_start() - chrono::Duration::weeks(2), duration_minutes: 30, meeting_url: Some("https://meet.example/stray".into()), ical_sequence: 9, ..create_test_session() }; - let mut with_strays = members.clone(); - with_strays.push(stray(None)); - with_strays.push(stray(Some(snapshot_start() + chrono::Duration::hours(1)))); + let off_slot = snapshot_start() + chrono::Duration::hours(1); + let with_strays = [members.clone(), vec![stray(None), stray(Some(off_slot))]].concat(); - assert_eq!(snapshot(&series, &with_strays), baseline); -} - -#[test] -fn snapshot_master_dtstart_is_rule_start_even_if_first_moved() { - let series = snapshot_series(snapshot_start()); - let mut members = snapshot_members(&series); - members[0].date += chrono::Duration::days(1); - let ics = snapshot(&series, &members); - - let events = vevents(&ics); - assert_eq!(events.len(), 2); - assert!(events[0].contains("DTSTART;TZID=America/Chicago:20260915T140000")); - assert!(events[0].contains("RRULE:")); - assert!(events[1].contains("RECURRENCE-ID;TZID=America/Chicago:20260915T140000")); - assert!(events[1].contains("DTSTART;TZID=America/Chicago:20260916T140000")); + assert_eq!(snapshot(&series, &with_strays), snapshot(&series, &members)); } #[cfg(feature = "mock")] @@ -3369,84 +3337,76 @@ async fn test_rescheduling_a_series_clears_the_standalone_events_it_replaced() { ); } -/// Loader for the series `.ics` description: the first session's in-progress goals. -#[cfg(feature = "mock")] -fn mock_series_description_loader() -> DatabaseConnection { - MockDatabase::new(DatabaseBackend::Postgres) - .append_query_results::(vec![vec![]]) - .into_connection() -} - -/// A series with slot 4 deleted and slot 3 moved a day later. +/// The two invites a series sends. #[cfg(feature = "mock")] -fn edited_series_rows(series: &coaching_session_series::Model) -> Vec { - let mut rows = snapshot_members(series); - rows[2].date += chrono::Duration::days(1); - rows.remove(3); - rows +enum SeriesSend { + Resend, + Scheduled, } -/// Mocks one recipient's send, told apart by the `to` address. +/// Sends a series with slot 4 deleted and slot 3 moved, the coachee's send answered with +/// `coachee_status`. Returns the resend's outcome, if any, and the last send's attachments. #[cfg(feature = "mock")] -async fn mock_send_to( - server: &mut ServerGuard, - email: &str, - status: usize, - capture: &AttachmentsCapture, -) -> mockito::Mock { - server - .mock("POST", "/emails") - .match_body(mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#))) - .match_request(capture.recorder()) - .with_status(status) - .with_body(r#"{"id":"email_test"}"#) - .expect(1) - .create_async() - .await -} - -#[cfg(feature = "mock")] -#[tokio::test] -async fn series_resend_attaches_snapshot() { +async fn send_edited_series( + send: SeriesSend, + coachee_status: usize, +) -> (Option, Vec<(String, String)>) { let mut server = setup_test_server().await; let config = create_full_config_with_mock(&server.url()); let capture = AttachmentsCapture::default(); - let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); - let org = create_test_organization(); - let series = snapshot_series(snapshot_start()); - let rows = edited_series_rows(&series); - - let mock_coachee = mock_send_to(&mut server, "jane@example.com", 200, &capture).await; - let mock_coach = mock_send_to(&mut server, "alex@example.com", 200, &capture).await; + let (series, org) = ( + snapshot_series(snapshot_start(), 1), + create_test_organization(), + ); + let rows = edit_members(&series, &[3], &[(2, 1)]); + // Loader for the series `.ics` description: the first session's in-progress goals. + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results::(vec![vec![]]) + .into_connection(); - let delivered = send_series_invite_resend( - &mock_series_description_loader(), - &config, - &series, - &Participants { - coach: &coach, - coachee: &coachee, - }, - &rows, - &rows, - &org, - ) - .await - .unwrap(); + let mut mocks = Vec::new(); + for (email, status) in [("jane@", coachee_status), ("alex@", 200)] { + let to = mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#)); + let mock = server.mock("POST", "/emails").match_body(to); + let mock = mock.match_request(capture.recorder()).with_status(status); + let mock = mock.with_body(r#"{"id":"e"}"#).expect(1); + mocks.push(mock.create_async().await); + } - mock_coachee.assert_async().await; - mock_coach.assert_async().await; - assert_eq!( - delivered, - Delivered { - coachee: true, - coach: true + let delivered = match send { + SeriesSend::Resend => { + let participants = Participants { + coach: &coach, + coachee: &coachee, + }; + let delivered = + send_series_invite_resend(&db, &config, &series, &participants, &rows, &rows, &org); + Some(delivered.await.unwrap()) } - ); + SeriesSend::Scheduled => { + send_recurring_sessions_scheduled_email( + &db, &config, &series, &coach, &coachee, &rows, &org, + ) + .await + .unwrap(); + None + } + }; + for mock in mocks { + mock.assert_async().await; + } + (delivered, capture.captured()) +} - let attachments = capture.captured(); +#[cfg(feature = "mock")] +#[tokio::test] +async fn series_resend_attaches_snapshot() { + let (delivered, attachments) = send_edited_series(SeriesSend::Resend, 200).await; + + let delivered = delivered.unwrap(); + assert_eq!((delivered.coachee, delivered.coach), (true, true)); assert_eq!(attachments.len(), 1); let ics = &attachments[0].1; assert!(ics.contains("METHOD:REQUEST")); @@ -3458,85 +3418,20 @@ async fn series_resend_attaches_snapshot() { #[cfg(feature = "mock")] #[tokio::test] async fn series_resend_reports_coachee_failure() { - let mut server = setup_test_server().await; - let config = create_full_config_with_mock(&server.url()); - let capture = AttachmentsCapture::default(); + let (delivered, _) = send_edited_series(SeriesSend::Resend, 400).await; - let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); - let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); - let org = create_test_organization(); - let series = snapshot_series(snapshot_start()); - let rows = edited_series_rows(&series); - - let mock_coachee = mock_send_to(&mut server, "jane@example.com", 400, &capture).await; - let mock_coach = mock_send_to(&mut server, "alex@example.com", 200, &capture).await; - - let delivered = send_series_invite_resend( - &mock_series_description_loader(), - &config, - &series, - &Participants { - coach: &coach, - coachee: &coachee, - }, - &rows, - &rows, - &org, - ) - .await - .unwrap(); - - mock_coachee.assert_async().await; - mock_coach.assert_async().await; - assert_eq!( - delivered, - Delivered { - coachee: false, - coach: true - } - ); + let delivered = delivered.unwrap(); + assert_eq!((delivered.coachee, delivered.coach), (false, true)); } #[cfg(feature = "mock")] #[tokio::test] async fn series_scheduled_still_master_only() { - let mut server = setup_test_server().await; - let config = create_full_config_with_mock(&server.url()); - let capture = AttachmentsCapture::default(); - - let coach = create_test_user_with("Alex", "Smith", "alex@example.com", "America/Chicago"); - let coachee = create_test_user_with("Jane", "Doe", "jane@example.com", "UTC"); - let org = create_test_organization(); - let series = snapshot_series(snapshot_start()); - let rows = edited_series_rows(&series); - - let mock = server - .mock("POST", "/emails") - .match_request(capture.recorder()) - .with_status(200) - .with_body(r#"{"id":"email_test"}"#) - .expect(2) - .create_async() - .await; + let (_, attachments) = send_edited_series(SeriesSend::Scheduled, 200).await; - let result = send_recurring_sessions_scheduled_email( - &mock_series_description_loader(), - &config, - &series, - &coach, - &coachee, - &rows, - &org, - ) - .await; - assert!(result.is_ok()); - mock.assert_async().await; - - let attachments = capture.captured(); assert_eq!(attachments.len(), 1); - let ics = &attachments[0].1; - assert_eq!(ics.matches("EXDATE").count(), 0); - assert_eq!(ics.matches("BEGIN:VEVENT").count(), 1); + assert_eq!(attachments[0].1.matches("EXDATE").count(), 0); + assert_eq!(attachments[0].1.matches("BEGIN:VEVENT").count(), 1); } /// A reschedule can legitimately leave no future occurrences. There is then no series diff --git a/domain/src/gateway/ical_tests.rs b/domain/src/gateway/ical_tests.rs index 82403ac30..589f85fc6 100644 --- a/domain/src/gateway/ical_tests.rs +++ b/domain/src/gateway/ical_tests.rs @@ -257,69 +257,56 @@ fn override_instance_never_carries_an_rrule() { assert!(out.contains("RECURRENCE-ID;TZID=America/New_York:20260908T150000")); } -fn weekly_count(count: u32) -> Recurrence { - Recurrence { +/// A six-week weekly series anchored in `tz`, or a one-off when `weekly` is false. +fn invite_in(tz: chrono_tz::Tz, weekly: bool) -> IcsInvite<'static> { + let recurrence = weekly.then_some(Recurrence { frequency: Frequency::Weekly, interval: 1, by_weekdays: None, - count: Some(count), + count: Some(6), until: None, - } + }); + invite(person(COACH), person(COACHEE), tz, recurrence) } /// Clients match an EXDATE against DTSTART, so it must use the same zoned or `Z` form. #[test] fn exdate_matches_dtstart_timezone_form() { - let mut zoned = invite( - person(COACH), - person(COACHEE), - chrono_tz::America::Chicago, - Some(weekly_count(6)), - ); - zoned.exdates = vec![dt(2026, 9, 22, 19, 0), dt(2026, 10, 6, 19, 0)]; + let zoned = IcsInvite { + exdates: vec![dt(2026, 9, 22, 19, 0), dt(2026, 10, 6, 19, 0)], + ..invite_in(chrono_tz::America::Chicago, true) + }; let out = build(&zoned).unwrap(); assert_eq!(out.matches("EXDATE;TZID=America/Chicago:").count(), 2); assert!(out.contains("EXDATE;TZID=America/Chicago:20260922T140000")); assert!(out.contains("EXDATE;TZID=America/Chicago:20261006T140000")); - let mut utc = invite( - person(COACH), - person(COACHEE), - chrono_tz::UTC, - Some(weekly_count(6)), - ); - utc.exdates = vec![dt(2026, 9, 22, 19, 0)]; - let out = build(&utc).unwrap(); - assert!(out.contains("EXDATE:20260922T190000Z")); + let utc = IcsInvite { + exdates: vec![dt(2026, 9, 22, 19, 0)], + ..invite_in(chrono_tz::UTC, true) + }; + assert!(build(&utc).unwrap().contains("EXDATE:20260922T190000Z")); } /// EXDATEs only mean something against an RRULE, which an override never carries. #[test] fn exdates_dropped_without_rrule() { - let mut inv = invite( - person(COACH), - person(COACHEE), - New_York, - Some(weekly_count(6)), - ); - inv.recurrence_id = Some(dt(2026, 9, 8, 19, 0)); - inv.exdates = vec![dt(2026, 9, 22, 19, 0)]; - let out = build(&inv).unwrap(); - assert!(!out.contains("EXDATE")); + let inv = IcsInvite { + recurrence_id: Some(dt(2026, 9, 8, 19, 0)), + exdates: vec![dt(2026, 9, 22, 19, 0)], + ..invite_in(New_York, true) + }; + assert!(!build(&inv).unwrap().contains("EXDATE")); } #[test] fn build_calendar_multiple_events_single_vtimezone() { - let master = invite( - person(COACH), - person(COACHEE), - New_York, - Some(weekly_count(6)), - ); - let mut moved = invite(person(COACH), person(COACHEE), New_York, None); - moved.recurrence_id = Some(dt(2026, 9, 22, 19, 0)); - moved.start = dt(2026, 9, 23, 19, 0); - let out = build_calendar(&[master, moved]).unwrap(); + let moved = IcsInvite { + recurrence_id: Some(dt(2026, 9, 22, 19, 0)), + start: dt(2026, 9, 23, 19, 0), + ..invite_in(New_York, false) + }; + let out = build_calendar(&[invite_in(New_York, true), moved]).unwrap(); assert_eq!(out.matches("BEGIN:VEVENT").count(), 2); assert_eq!(out.matches("BEGIN:VTIMEZONE").count(), 1); @@ -330,15 +317,8 @@ fn build_calendar_multiple_events_single_vtimezone() { #[test] fn build_calendar_rejects_empty_and_mixed_zones() { assert!(build_calendar(&[]).is_err()); - - let ny = invite(person(COACH), person(COACHEE), New_York, None); - let chicago = invite( - person(COACH), - person(COACHEE), - chrono_tz::America::Chicago, - None, - ); - assert!(build_calendar(&[ny, chicago]).is_err()); + let chicago = invite_in(chrono_tz::America::Chicago, false); + assert!(build_calendar(&[invite_in(New_York, false), chicago]).is_err()); } /// RFC 5545 line unfolding: remove the CRLF followed by a leading space/tab. diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 831bf72fd..1b39aa33d 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -1938,27 +1938,10 @@ mod tests { #[tokio::test] async fn raise_series_ical_sequence_uses_greatest() -> Result<(), Error> { - let now = chrono::Utc::now(); let series_id = Id::new_v4(); let raised = Model { - id: Id::new_v4(), - coaching_relationship_id: Id::new_v4(), - coaching_session_series_id: Some(series_id), ical_sequence: 4, - ical_recurrence_id: None, - collab_document_name: None, - date: chrono::NaiveDate::from_ymd_opt(2026, 6, 1) - .unwrap() - .and_hms_opt(10, 0, 0) - .unwrap(), - duration_minutes: crate::duration::Duration::default_minutes(), - title: None, - meeting_url: None, - provider: None, - created_at: now.into(), - updated_at: now.into(), - hydrated_at: None, - notice_given_at: now.into(), + ..series_session(Some(series_id), naive("2026-06-01 10:00:00")) }; let db = MockDatabase::new(DatabaseBackend::Postgres) diff --git a/web/src/controller/resend_invite_tests.rs b/web/src/controller/resend_invite_tests.rs index 0df942419..04c2e726c 100644 --- a/web/src/controller/resend_invite_tests.rs +++ b/web/src/controller/resend_invite_tests.rs @@ -18,7 +18,7 @@ use domain::{ }; use mockito::{Mock, Server, ServerGuard}; use password_auth::generate_hash; -use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; +use sea_orm::{prelude::DateTimeWithTimeZone, DatabaseBackend, MockDatabase}; use service::config::Config; use time::Duration; use tower::ServiceExt; @@ -28,30 +28,39 @@ use crate::controller::user_session_controller; use crate::router::{coaching_session_series_routes, coaching_sessions_routes}; use crate::AppState; -const COACH_EMAIL: &str = "coach@example.com"; -const COACHEE_EMAIL: &str = "coachee@example.com"; - -fn user(email: &str) -> users::Model { - let now = Utc::now(); - users::Model { - id: Id::new_v4(), - email: email.to_string(), - first_name: "Test".to_string(), - last_name: "User".to_string(), - display_name: None, - password: Some(generate_hash("password123")), - github_username: None, - github_profile_url: None, - timezone: "UTC".to_string(), - default_coaching_session_duration_minutes: domain::duration::Duration::default_minutes(), - roles: vec![], - invite_status: None, - created_at: now.into(), - updated_at: now.into(), +type Series = coaching_session_series::Model; +type Session = coaching_sessions::Model; + +fn now() -> DateTimeWithTimeZone { + Utc::now().into() +} + +/// A whole hour `days` from now, so it is minute-aligned. +fn start_in(days: i64) -> NaiveDateTime { + (Utc::now() + chrono::Duration::days(days)) + .date_naive() + .and_hms_opt(15, 0, 0) + .unwrap() +} + +/// One resend `POST`: who logs in, where it goes, and the loads it will make. +struct Call { + caller: String, + path: String, + db: MockDatabase, +} + +impl Call { + fn then(self, more: impl FnOnce(MockDatabase) -> MockDatabase) -> Self { + Self { + db: more(self.db), + ..self + } } } -/// A coach and coachee in one organization, and the relationship between them. +/// A coach and coachee in one organization and the relationship between them, building +/// the calls each route makes. struct Fixture { coach: users::Model, coachee: users::Model, @@ -61,16 +70,30 @@ struct Fixture { impl Fixture { fn new() -> Self { - let now = Utc::now(); - let coach = user(COACH_EMAIL); - let coachee = user(COACHEE_EMAIL); + let user = |email: &str| users::Model { + id: Id::new_v4(), + email: email.to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + display_name: None, + password: Some(generate_hash("password123")), + github_username: None, + github_profile_url: None, + timezone: "UTC".to_string(), + default_coaching_session_duration_minutes: 60, + roles: vec![], + invite_status: None, + created_at: now(), + updated_at: now(), + }; + let (coach, coachee) = (user("coach@example.com"), user("coachee@example.com")); let organization = organizations::Model { id: Id::new_v4(), name: "Acme".to_string(), logo: None, slug: "acme".to_string(), - created_at: now.into(), - updated_at: now.into(), + created_at: now(), + updated_at: now(), archived_at: None, archived_by: None, }; @@ -80,8 +103,8 @@ impl Fixture { coach_id: coach.id, coachee_id: coachee.id, slug: "test".to_string(), - created_at: now.into(), - updated_at: now.into(), + created_at: now(), + updated_at: now(), }; Self { coach, @@ -91,41 +114,8 @@ impl Fixture { } } - fn role(&self, user: &users::Model) -> user_roles::Model { - let now = Utc::now(); - user_roles::Model { - id: Id::new_v4(), - role: users::Role::User, - organization_id: Some(self.organization.id), - user_id: user.id, - created_at: now.into(), - updated_at: now.into(), - } - } - - /// The login and `require_auth` loads for `caller`. - fn logged_in_as(&self, caller: &users::Model) -> MockDatabase { - let row = (caller.clone(), self.role(caller)); - MockDatabase::new(DatabaseBackend::Postgres) - .append_query_results([vec![row.clone()]]) - .append_query_results([vec![row]]) - } - - /// The loads `InviteParties::load` makes. - fn with_parties(&self, db: MockDatabase) -> MockDatabase { - db.append_query_results(vec![vec![self.relationship.clone()]]) - .append_query_results(vec![vec![self.coach.clone()]]) - .append_query_results(vec![vec![self.coachee.clone()]]) - .append_query_results(vec![vec![self.organization.clone()]]) - } - - fn series( - &self, - start_at: NaiveDateTime, - ical_sequence: i32, - ) -> coaching_session_series::Model { - let now = Utc::now(); - coaching_session_series::Model { + fn series(&self, start_at: NaiveDateTime) -> Series { + Series { id: Id::new_v4(), coaching_relationship_id: self.relationship.id, rule: serde_json::json!({ @@ -133,16 +123,15 @@ impl Fixture { "recurrence": { "frequency": "weekly", "interval": 1, "count": 3 }, "duration_minutes": 60, }), - ical_sequence, + ical_sequence: 3, created_by_user_id: self.coach.id, - created_at: now.into(), - updated_at: now.into(), + created_at: now(), + updated_at: now(), } } - fn session(&self, date: NaiveDateTime) -> coaching_sessions::Model { - let now = Utc::now(); - coaching_sessions::Model { + fn session(&self, date: NaiveDateTime) -> Session { + Session { id: Id::new_v4(), coaching_relationship_id: self.relationship.id, coaching_session_series_id: None, @@ -154,61 +143,195 @@ impl Fixture { title: None, meeting_url: None, provider: None, - created_at: now.into(), - updated_at: now.into(), + created_at: now(), + updated_at: now(), hydrated_at: None, - notice_given_at: now.into(), + notice_given_at: now(), } } /// One member per slot of `series`, each in the slot it was created for. - fn members(&self, series: &coaching_session_series::Model) -> Vec { + fn members(&self, series: &Series) -> Vec { let start_at: NaiveDateTime = serde_json::from_value(series.rule["start_at"].clone()).unwrap(); (0..3) .map(|week| start_at + chrono::Duration::weeks(week)) - .map(|slot| coaching_sessions::Model { + .map(|slot| Session { coaching_session_series_id: Some(series.id), ical_recurrence_id: Some(slot), ..self.session(slot) }) .collect() } + + /// The login and `require_auth` loads for `caller`. + fn login(&self, caller: &users::Model, path: String) -> Call { + let role = user_roles::Model { + id: Id::new_v4(), + role: users::Role::User, + organization_id: Some(self.organization.id), + user_id: caller.id, + created_at: now(), + updated_at: now(), + }; + let row = (caller.clone(), role); + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![row.clone()]]) + .append_query_results([vec![row]]); + Call { + caller: caller.email.clone(), + path, + db, + } + } + + /// The loads `InviteParties::load` makes. + fn parties(&self, db: MockDatabase) -> MockDatabase { + db.append_query_results([vec![self.relationship.clone()]]) + .append_query_results([vec![self.coach.clone()]]) + .append_query_results([vec![self.coachee.clone()]]) + .append_query_results([vec![self.organization.clone()]]) + } + + fn series_gate(&self, caller: &users::Model, series: &Series) -> Call { + let path = format!("/coaching_session_series/{}/resend_invite", series.id); + self.login(caller, path).then(|db| { + db.append_query_results([vec![series.clone()]]) + .append_query_results([vec![self.relationship.clone()]]) + }) + } + + /// The coach past the gate, then the domain's series and member reads. + fn series_loaded(&self, series: &Series, rows: Vec) -> Call { + self.series_gate(&self.coach, series).then(|db| { + db.append_query_results([vec![series.clone()]]) + .append_query_results([rows]) + }) + } + + /// Up to the send: the bump, the members raised to it, the parties, then the goals + /// the description lists. + fn series_sent(&self, series: &Series, rows: Vec) -> Call { + let bumped = series.ical_sequence + 1; + let raised: Vec<_> = rows + .iter() + .map(|row| Session { + ical_sequence: row.ical_sequence.max(bumped), + ..row.clone() + }) + .collect(); + let bumped = Series { + ical_sequence: bumped, + ..series.clone() + }; + self.series_loaded(series, rows).then(|db| { + self.parties( + db.append_query_results([vec![bumped]]) + .append_query_results([raised]), + ) + .append_query_results::([vec![]]) + }) + } + + fn session_gate(&self, caller: &users::Model, session: &Session) -> Call { + let path = format!("/coaching_sessions/{}/resend_invite", session.id); + self.login(caller, path).then(|db| { + db.append_query_results([vec![(session.clone(), self.relationship.clone())]]) + }) + } + + /// The coach past the gate, then the domain's read. + fn session_loaded(&self, session: &Session) -> Call { + self.session_gate(&self.coach, session) + .then(|db| db.append_query_results([vec![session.clone()]])) + } + + /// Up to the send: the bump, the parties, then the topics, goals and open actions + /// the description lists. + fn session_sent(&self, session: &Session) -> Call { + let bumped = Session { + ical_sequence: session.ical_sequence + 1, + ..session.clone() + }; + self.session_loaded(session).then(|db| { + self.parties(db.append_query_results([vec![bumped]])) + .append_query_results::([vec![]]) + .append_query_results::([vec![]]) + .append_query_results::([vec![]]) + }) + } } -/// A whole-hour start a week out, so it is upcoming and minute-aligned. -fn upcoming_start() -> NaiveDateTime { - (Utc::now() + chrono::Duration::days(7)) - .date_naive() - .and_hms_opt(15, 0, 0) - .unwrap() +struct Outcome { + status: StatusCode, + reason: Option, + /// The unfolded `.ics` of every Resend request. + sent: Vec, + /// Every UPDATE that wrote an `ical_sequence`. + sequence_updates: Vec, } -fn past_start() -> NaiveDateTime { - (Utc::now() - chrono::Duration::days(30)) - .date_naive() - .and_hms_opt(15, 0, 0) - .unwrap() +impl Outcome { + fn updated(&self, table: &str, also: &str) -> bool { + let target = format!(r#"UPDATE "refactor_platform"."{table}""#); + let updated = |sql: &&String| sql.contains(&target) && sql.contains(also); + self.sequence_updates.iter().any(|sql| updated(&sql)) + } +} + +/// Mocks Resend: the coachee gets `coachee_status` and the coach 200, or with `None`, +/// no request may arrive. +async fn mock_resend( + server: &mut ServerGuard, + sent: &Arc>>, + coachee_status: Option, +) -> Vec { + let Some(coachee_status) = coachee_status else { + let none = server.mock("POST", "/emails").expect(0); + return vec![none.create_async().await]; + }; + let mut mocks = Vec::new(); + for (email, status) in [("coachee@", coachee_status), ("coach@", 200)] { + let sent = sent.clone(); + let record = move |request: &mockito::Request| { + let ics = serde_json::from_slice::(request.body().unwrap()) + .ok() + .and_then(|body| body["attachments"][0]["content"].as_str().map(String::from)) + .and_then(|content| STANDARD.decode(content).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + let unfolded = ics.replace("\r\n ", "").replace("\r\n\t", ""); + sent.lock().unwrap().push(unfolded); + true + }; + let to = mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#)); + let mock = server + .mock("POST", "/emails") + .match_body(to) + .match_request(record); + let mock = mock + .with_status(status) + .with_body(r#"{"id":"e"}"#) + .expect(1); + mocks.push(mock.create_async().await); + } + mocks } -fn config(resend_url: &str) -> Config { - Config::from_args([ +/// Logs the caller in over the real routers, then sends the resend `POST`. +async fn resend(call: Call, coachee_status: Option) -> Outcome { + let mut server = Server::new_async().await; + let sent = Arc::new(Mutex::new(Vec::new())); + let mocks = mock_resend(&mut server, &sent, coachee_status).await; + let config = Config::from_args([ "test", "--resend-api-key=test_api_key_123", "--session-scheduled-email-template-id=session_template", "--recurring-sessions-scheduled-email-template-id=series_template", "--frontend-base-url=https://app.example.com", - &format!("--resend-base-url={resend_url}"), - ]) -} - -/// Logs `caller_email` in over the real routers, then sends the resend `POST`. -async fn post_resend( - db: Arc, - config: Config, - caller_email: &str, - uri: String, -) -> axum::response::Response { + &format!("--resend-base-url={}", server.url()), + ]); + let db = Arc::new(call.db.into_connection()); let app_state = AppState::new( service::AppState::new(config, &db), Arc::new(sse::Manager::default()), @@ -221,523 +344,141 @@ async fn post_resend( .with_secure(false) .with_expiry(Expiry::OnInactivity(Duration::days(1))) .with_always_save(true); - let auth_layer = AuthManagerLayerBuilder::new(Backend::new(&db), session_layer).build(); - let app = Router::new() .route("/login", post(user_session_controller::login)) .with_state(app_state.clone()) .merge(coaching_sessions_routes(app_state.clone())) .merge(coaching_session_series_routes(app_state)) - .layer(auth_layer); + .layer(AuthManagerLayerBuilder::new(Backend::new(&db), session_layer).build()); - let login = Request::builder() - .uri("/login") - .method("POST") + let form = format!("email={}&password=password123", call.caller); + let login = Request::post("/login") .header("content-type", "application/x-www-form-urlencoded") - .body(Body::from(format!( - "email={caller_email}&password=password123" - ))) + .body(Body::from(form)) .unwrap(); - let cookie = app - .clone() - .oneshot(login) - .await - .unwrap() - .headers() - .get("set-cookie") - .and_then(|c| c.to_str().ok()) - .expect("login should set a session cookie") - .to_string(); - - let request = Request::builder() - .uri(uri) - .method("POST") - .header("cookie", cookie) + let login = app.clone().oneshot(login).await.unwrap(); + let request = Request::post(call.path) + .header("cookie", login.headers()["set-cookie"].clone()) .header("x-version", "1.0.0") .body(Body::empty()) .unwrap(); - app.oneshot(request).await.unwrap() -} - -async fn conflict_reason(response: axum::response::Response) -> String { - assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - body["details"]["reason"].as_str().unwrap().to_string() -} - -/// Whether any statement issued updates `table`'s `ical_sequence`. -fn bumped_sequence_of(db: DatabaseConnection, table: &str) -> bool { - let target = format!(r#"UPDATE "refactor_platform"."{table}""#); - db.into_transaction_log() - .iter() - .flat_map(|txn| txn.statements()) - .any(|stmt| stmt.sql.contains(&target) && stmt.sql.contains("ical_sequence")) -} - -/// The unfolded `.ics` attached to a Resend request, if any. -fn attached_ics(request: &mockito::Request) -> Option { - let payload: serde_json::Value = serde_json::from_slice(request.body().ok()?).ok()?; - let content = payload["attachments"][0]["content"].as_str()?; - let ics = String::from_utf8(STANDARD.decode(content).ok()?).ok()?; - Some(ics.replace("\r\n ", "").replace("\r\n\t", "")) -} - -/// Every `.ics` the Resend mock received, in order. -#[derive(Clone, Default)] -struct Received(Arc>>); - -impl Received { - fn recorder(&self) -> impl Fn(&mockito::Request) -> bool + Send + Sync + 'static { - let received = self.0.clone(); - move |request| { - received - .lock() - .unwrap() - .push(attached_ics(request).unwrap_or_default()); - true - } + let response = app.oneshot(request).await.unwrap(); + + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await; + let reason = serde_json::from_slice::(&body.unwrap()) + .ok() + .and_then(|body| body["details"]["reason"].as_str().map(String::from)); + for mock in mocks { + mock.assert_async().await; } - - fn all(&self) -> Vec { - self.0.lock().unwrap().clone() - } -} - -/// One recipient's send, told apart by the `to` address. -async fn mock_send_to( - server: &mut ServerGuard, - email: &str, - status: usize, - received: &Received, -) -> Mock { - server - .mock("POST", "/emails") - .match_body(mockito::Matcher::Regex(format!(r#""to":\[[^\]]*{email}"#))) - .match_request(received.recorder()) - .with_status(status) - .with_body(r#"{"id":"email_test"}"#) - .expect(1) - .create_async() - .await -} - -/// The VEVENT carrying the RRULE. -fn master_vevent(ics: &str) -> &str { - ics.split("BEGIN:VEVENT") - .skip(1) - .find(|event| event.contains("RRULE")) - .expect("the snapshot has a master VEVENT") -} - -// ── Series ───────────────────────────────────────────────────────── - -#[tokio::test] -async fn series_resend_forbids_the_coachee_and_sends_nothing() { - let mut server = Server::new_async().await; - let no_send = server - .mock("POST", "/emails") - .expect(0) - .create_async() - .await; - let fixture = Fixture::new(); - let series = fixture.series(upcoming_start(), 3); - - let db = Arc::new( - fixture - .logged_in_as(&fixture.coachee) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![vec![fixture.relationship.clone()]]) - .into_connection(), - ); - - let response = post_resend( - db, - config(&server.url()), - COACHEE_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - no_send.assert_async().await; -} - -#[tokio::test] -async fn series_resend_refuses_a_series_with_nothing_upcoming() { - let server = Server::new_async().await; - let fixture = Fixture::new(); - let series = fixture.series(past_start(), 3); - let rows = fixture.members(&series); - - let db = Arc::new( - fixture - .logged_in_as(&fixture.coach) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![vec![fixture.relationship.clone()]]) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![rows]) - .into_connection(), - ); - - let response = post_resend( - db.clone(), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(conflict_reason(response).await, "no_upcoming_sessions"); - let db = Arc::try_unwrap(db).expect("the app has been dropped"); - assert!(!bumped_sequence_of(db, "coaching_session_series")); -} - -#[tokio::test] -async fn series_resend_refuses_a_legacy_series() { - let server = Server::new_async().await; - let fixture = Fixture::new(); - let series = fixture.series(upcoming_start(), 3); - let mut rows = fixture.members(&series); - rows[1].ical_recurrence_id = None; - - let db = Arc::new( - fixture - .logged_in_as(&fixture.coach) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![vec![fixture.relationship.clone()]]) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![rows]) - .into_connection(), - ); - - let response = post_resend( - db, - config(&server.url()), - COACH_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(conflict_reason(response).await, "legacy_series"); -} - -/// The series loads up to the send: the series twice (gate, then domain), its rows, the -/// bump, the rows raised to the bump, the parties, then the in-progress goals the -/// description lists. -fn series_send_db( - fixture: &Fixture, - series: &coaching_session_series::Model, - rows: Vec, - bumped: &coaching_session_series::Model, -) -> Arc { - let raised: Vec = rows + let log = Arc::try_unwrap(db).expect("the app has been dropped"); + let sequence_updates = log + .into_transaction_log() .iter() - .map(|row| coaching_sessions::Model { - ical_sequence: row.ical_sequence.max(bumped.ical_sequence), - ..row.clone() - }) + .flat_map(|txn| txn.statements()) + .map(|stmt| stmt.sql.clone()) + .filter(|sql| sql.starts_with("UPDATE") && sql.contains("ical_sequence")) .collect(); - let db = fixture - .logged_in_as(&fixture.coach) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![vec![fixture.relationship.clone()]]) - .append_query_results(vec![vec![series.clone()]]) - .append_query_results(vec![rows]) - .append_query_results(vec![vec![bumped.clone()]]) - .append_query_results(vec![raised]); - Arc::new( - fixture - .with_parties(db) - .append_query_results::(vec![vec![]]) - .into_connection(), - ) + let sent = sent.lock().unwrap().clone(); + Outcome { + status, + reason, + sent, + sequence_updates, + } } +/// Each refusal, by its 409 reason (`None` is a 403), sends nothing and bumps nothing. #[tokio::test] -async fn series_resend_sends_the_snapshot_under_the_next_sequence() { - let mut server = Server::new_async().await; - let fixture = Fixture::new(); - let series = fixture.series(upcoming_start(), 3); - let bumped = coaching_session_series::Model { - ical_sequence: 4, - ..series.clone() - }; - let rows = fixture.members(&series); - let received = Received::default(); - let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; - let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; - - let db = series_send_db(&fixture, &series, rows, &bumped); - let response = post_resend( - db.clone(), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - to_coachee.assert_async().await; - to_coach.assert_async().await; - let received = received.all(); - assert_eq!(received.len(), 2); - for ics in &received { - assert!(master_vevent(ics).contains("SEQUENCE:4"), "{ics}"); +async fn refusals_send_nothing_and_bump_nothing() { + let f = Fixture::new(); + let series = f.series(start_in(7)); + let past_series = f.series(start_in(-30)); + let mut legacy_rows = f.members(&series); + legacy_rows[1].ical_recurrence_id = None; + let one_off = f.session(start_in(7)); + let member = f.members(&series).remove(0); + + let cases = [ + (f.series_gate(&f.coachee, &series), None), + ( + f.series_loaded(&past_series, f.members(&past_series)), + Some("no_upcoming_sessions"), + ), + (f.series_loaded(&series, legacy_rows), Some("legacy_series")), + (f.session_gate(&f.coachee, &one_off), None), + (f.session_loaded(&member), Some("series_member")), + ( + f.session_loaded(&f.session(start_in(-30))), + Some("no_upcoming_sessions"), + ), + ]; + + for (case, (call, reason)) in cases.into_iter().enumerate() { + let status = match reason { + Some(_) => StatusCode::CONFLICT, + None => StatusCode::FORBIDDEN, + }; + let out = resend(call, None).await; + assert_eq!(out.status, status, "case {case}"); + assert_eq!(out.reason.as_deref(), reason, "case {case}"); + assert!(out.sequence_updates.is_empty(), "case {case}"); } - let db = Arc::try_unwrap(db).expect("the app has been dropped"); - assert!(bumped_sequence_of(db, "coaching_session_series")); } -/// Google drops an override whose SEQUENCE trails its master, so members rise with the bump. #[tokio::test] -async fn series_resend_raises_moved_members_to_the_master_sequence() { - let mut server = Server::new_async().await; - let fixture = Fixture::new(); - let series = fixture.series(upcoming_start(), 3); - let bumped = coaching_session_series::Model { - ical_sequence: 4, - ..series.clone() - }; - let mut rows = fixture.members(&series); +async fn series_resend_bumps_the_series_and_raises_its_members() { + let f = Fixture::new(); + let series = f.series(start_in(7)); + let mut rows = f.members(&series); rows[1].date += chrono::Duration::days(1); rows[1].ical_sequence = 1; - let received = Received::default(); - let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; - let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; - - let db = series_send_db(&fixture, &series, rows, &bumped); - let response = post_resend( - db.clone(), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - to_coachee.assert_async().await; - to_coach.assert_async().await; - for ics in &received.all() { - let master = master_vevent(ics); - let moved = ics - .split("BEGIN:VEVENT") - .skip(1) - .find(|event| event.contains("RECURRENCE-ID")) - .expect("the snapshot has an override VEVENT"); - assert!(master.contains("SEQUENCE:4"), "{ics}"); - assert!(moved.contains("SEQUENCE:4"), "{ics}"); - } - let db = Arc::try_unwrap(db).expect("the app has been dropped"); - let raised_members = db - .into_transaction_log() - .iter() - .flat_map(|txn| txn.statements()) - .any(|stmt| { - stmt.sql - .contains(r#"UPDATE "refactor_platform"."coaching_sessions""#) - && stmt.sql.contains("GREATEST") - }); - assert!( - raised_members, - "members must be raised to the series SEQUENCE" - ); -} - -#[tokio::test] -async fn series_resend_reports_an_unreached_coachee() { - let mut server = Server::new_async().await; - let fixture = Fixture::new(); - let series = fixture.series(upcoming_start(), 3); - let bumped = coaching_session_series::Model { - ical_sequence: 4, - ..series.clone() - }; - let rows = fixture.members(&series); - let received = Received::default(); - let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 400, &received).await; - let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; - - let response = post_resend( - series_send_db(&fixture, &series, rows, &bumped), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_session_series/{}/resend_invite", series.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - to_coachee.assert_async().await; - to_coach.assert_async().await; -} - -// ── Session ──────────────────────────────────────────────────────── - -#[tokio::test] -async fn session_resend_forbids_the_coachee() { - let mut server = Server::new_async().await; - let no_send = server - .mock("POST", "/emails") - .expect(0) - .create_async() - .await; - let fixture = Fixture::new(); - let session = fixture.session(upcoming_start()); - - let db = Arc::new( - fixture - .logged_in_as(&fixture.coachee) - .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) - .into_connection(), - ); - let response = post_resend( - db, - config(&server.url()), - COACHEE_EMAIL, - format!("/coaching_sessions/{}/resend_invite", session.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - no_send.assert_async().await; -} + let out = resend(f.series_sent(&series, rows), Some(200)).await; -/// The gate's load, then the domain's. -fn session_refusal_db( - fixture: &Fixture, - session: &coaching_sessions::Model, -) -> Arc { - Arc::new( - fixture - .logged_in_as(&fixture.coach) - .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) - .append_query_results(vec![vec![session.clone()]]) - .into_connection(), - ) + assert_eq!(out.status, StatusCode::NO_CONTENT); + assert_eq!(out.sent.len(), 2); + assert!(out.updated("coaching_session_series", "ical_sequence")); + assert!(out.updated("coaching_sessions", "GREATEST")); } #[tokio::test] -async fn session_resend_refuses_a_current_series_member() { - let server = Server::new_async().await; - let fixture = Fixture::new(); - let session = coaching_sessions::Model { - coaching_session_series_id: Some(Id::new_v4()), - ical_recurrence_id: Some(upcoming_start()), - ..fixture.session(upcoming_start()) - }; - - let response = post_resend( - session_refusal_db(&fixture, &session), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_sessions/{}/resend_invite", session.id), - ) - .await; - - assert_eq!(conflict_reason(response).await, "series_member"); -} +async fn series_resend_reports_an_unreached_coachee() { + let f = Fixture::new(); + let series = f.series(start_in(7)); -#[tokio::test] -async fn session_resend_refuses_a_past_one_off() { - let server = Server::new_async().await; - let fixture = Fixture::new(); - let session = fixture.session(past_start()); - - let response = post_resend( - session_refusal_db(&fixture, &session), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_sessions/{}/resend_invite", session.id), - ) - .await; - - assert_eq!(conflict_reason(response).await, "no_upcoming_sessions"); -} + let out = resend(f.series_sent(&series, f.members(&series)), Some(400)).await; -/// The session loads up to the send: the gate, the domain read, the bump, the parties, -/// then the topics, goals and open actions the description lists. -fn session_send_db( - fixture: &Fixture, - session: &coaching_sessions::Model, -) -> Arc { - let bumped = coaching_sessions::Model { - ical_sequence: session.ical_sequence + 1, - ..session.clone() - }; - let db = fixture - .logged_in_as(&fixture.coach) - .append_query_results(vec![vec![(session.clone(), fixture.relationship.clone())]]) - .append_query_results(vec![vec![session.clone()]]) - .append_query_results(vec![vec![bumped]]); - Arc::new( - fixture - .with_parties(db) - .append_query_results::(vec![vec![]]) - .append_query_results::(vec![vec![]]) - .append_query_results::(vec![vec![]]) - .into_connection(), - ) + assert_eq!(out.status, StatusCode::BAD_GATEWAY); } #[tokio::test] async fn session_resend_sends_a_legacy_member_standalone() { - let mut server = Server::new_async().await; - let fixture = Fixture::new(); - let session = coaching_sessions::Model { + let f = Fixture::new(); + let session = Session { coaching_session_series_id: Some(Id::new_v4()), - ical_recurrence_id: None, - ..fixture.session(upcoming_start()) + ..f.session(start_in(7)) }; - let received = Received::default(); - let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; - let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; - - let response = post_resend( - session_send_db(&fixture, &session), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_sessions/{}/resend_invite", session.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - to_coachee.assert_async().await; - to_coach.assert_async().await; - let received = received.all(); - assert_eq!(received.len(), 2); - for ics in &received { - assert!( - ics.contains(&format!("UID:{}@myrefactor.com", session.id)), - "{ics}" - ); + + let out = resend(f.session_sent(&session), Some(200)).await; + + assert_eq!(out.status, StatusCode::NO_CONTENT); + assert_eq!(out.sent.len(), 2); + for ics in &out.sent { + assert!(ics.contains(&format!("UID:{}@myrefactor.com", session.id))); assert!(!ics.contains("RECURRENCE-ID"), "{ics}"); } } #[tokio::test] -async fn session_resend_sends_a_one_off_under_the_next_sequence() { - let mut server = Server::new_async().await; - let fixture = Fixture::new(); - let session = fixture.session(upcoming_start()); - let received = Received::default(); - let to_coachee = mock_send_to(&mut server, COACHEE_EMAIL, 200, &received).await; - let to_coach = mock_send_to(&mut server, COACH_EMAIL, 200, &received).await; - - let db = session_send_db(&fixture, &session); - let response = post_resend( - db.clone(), - config(&server.url()), - COACH_EMAIL, - format!("/coaching_sessions/{}/resend_invite", session.id), - ) - .await; - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - to_coachee.assert_async().await; - to_coach.assert_async().await; - let db = Arc::try_unwrap(db).expect("the app has been dropped"); - assert!(bumped_sequence_of(db, "coaching_sessions")); +async fn session_resend_bumps_a_one_off() { + let f = Fixture::new(); + let session = f.session(start_in(7)); + + let out = resend(f.session_sent(&session), Some(200)).await; + + assert_eq!(out.status, StatusCode::NO_CONTENT); + assert_eq!(out.sent.len(), 2); + assert!(out.updated("coaching_sessions", "ical_sequence")); } From 606abeeadb02f4ccfd73ba6f0af584a98e4a03c6 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 13:49:39 -0500 Subject: [PATCH 11/11] fix: address review on resend invite --- domain/src/emails.rs | 11 ++- domain/src/emails_tests.rs | 26 ++++++ entity_api/src/coaching_session.rs | 16 ++-- .../controller/coaching_session_controller.rs | 1 + .../coaching_session_series_controller.rs | 1 + web/src/controller/resend_invite_tests.rs | 87 ++++++++++++++----- 6 files changed, 115 insertions(+), 27 deletions(-) diff --git a/domain/src/emails.rs b/domain/src/emails.rs index edc87bfc5..db0d5d7ef 100644 --- a/domain/src/emails.rs +++ b/domain/src/emails.rs @@ -1679,9 +1679,18 @@ fn build_series_snapshot_ics( ..base() }; + let master_url = master.location_url.clone(); let overrides = slotted.iter().filter_map(|(slot, member)| { member - .filter(|m| m.date != *slot || m.duration_minutes != rule.duration_minutes) + .filter(|m| { + // Zoom-style per-session links must not inherit another session's URL; an + // unassigned URL keeps the series' one. + let own_url = m + .meeting_url + .as_deref() + .is_some_and(|url| Some(url) != master_url.as_deref()); + m.date != *slot || m.duration_minutes != rule.duration_minutes || own_url + }) .map(|m| ical::IcsInvite { // Google drops an override whose SEQUENCE trails its master. sequence: m.ical_sequence.max(series.ical_sequence), diff --git a/domain/src/emails_tests.rs b/domain/src/emails_tests.rs index 8cfb82216..9b944fefc 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -2611,6 +2611,32 @@ fn snapshot_override_sequence_is_at_least_the_master() { assert_snapshot(&leading, (FIRST, 3), &[], &[moved(5)]); } +/// The snapshot with slot `index` unmoved but carrying `meeting_url`. +fn snapshot_with_url(index: usize, meeting_url: Option<&str>) -> String { + let series = snapshot_series(snapshot_start(), 1); + let mut members = snapshot_members(&series); + members[index].meeting_url = meeting_url.map(Into::into); + snapshot(&series, &members) +} + +#[test] +fn snapshot_unmoved_member_with_own_url_becomes_override() { + let ics = snapshot_with_url(2, Some("https://meet.example/own")); + let unmoved = ("20260929T140000", "20260929T140000", 1); + assert_snapshot(&ics, (FIRST, 1), &[], &[unmoved]); + let override_event = &ics[ics.rfind("BEGIN:VEVENT").unwrap()..]; + assert!( + override_event.contains("LOCATION:https://meet.example/own"), + "{ics}" + ); +} + +#[test] +fn snapshot_unmoved_member_without_url_inherits_master() { + let ics = snapshot_with_url(2, None); + assert_snapshot(&ics, (FIRST, 1), &[], &[]); +} + #[test] fn snapshot_ignores_non_slot_and_null_members() { let series = snapshot_series(snapshot_start(), 1); diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 1b39aa33d..dcdb4e15d 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -1398,7 +1398,7 @@ mod tests { use super::*; use entity::meeting_provider::Provider; use entity::Id; - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Statement, Transaction, Value}; /// The claim has to select and write in one statement, and the conflict clause has /// to skip rows already current. If a refactor split it into a SELECT followed by an @@ -1939,8 +1939,9 @@ mod tests { #[tokio::test] async fn raise_series_ical_sequence_uses_greatest() -> Result<(), Error> { let series_id = Id::new_v4(); + let floor = 7; let raised = Model { - ical_sequence: 4, + ical_sequence: floor, ..series_session(Some(series_id), naive("2026-06-01 10:00:00")) }; @@ -1948,21 +1949,24 @@ mod tests { .append_query_results(vec![vec![raised.clone()]]) .into_connection(); - let result = raise_series_ical_sequence(&db, series_id, 4).await?; + let result = raise_series_ical_sequence(&db, series_id, floor).await?; assert_eq!(result, vec![raised]); - let statements: Vec = db + let statements: Vec = db .into_transaction_log() .iter() .flat_map(|txn| txn.statements()) - .map(|stmt| stmt.sql.clone()) + .cloned() .collect(); assert_eq!(statements.len(), 1, "{statements:?}"); - let sql = &statements[0]; + let sql = &statements[0].sql; assert!(sql.starts_with("UPDATE"), "{sql}"); assert!(sql.contains(r#""coaching_sessions""#), "{sql}"); assert!(sql.contains("GREATEST"), "{sql}"); assert!(sql.contains(r#""coaching_session_series_id" = "#), "{sql}"); + let values = &statements[0].values.as_ref().expect("bound values").0; + assert!(values.contains(&Value::from(floor)), "{values:?}"); + assert!(values.contains(&Value::from(series_id)), "{values:?}"); Ok(()) } diff --git a/web/src/controller/coaching_session_controller.rs b/web/src/controller/coaching_session_controller.rs index 7fc365f4d..862593775 100644 --- a/web/src/controller/coaching_session_controller.rs +++ b/web/src/controller/coaching_session_controller.rs @@ -294,6 +294,7 @@ pub async fn delete( params(ApiVersion, ("id" = Uuid, Path, description = "Coaching Session ID")), responses( (status = 204, description = "Invite resent"), + (status = 400, description = "Missing or unsupported API version header"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden — not the coach on the relationship"), (status = 404, description = "Coaching Session not found"), diff --git a/web/src/controller/coaching_session_series_controller.rs b/web/src/controller/coaching_session_series_controller.rs index 8da0677a7..7c540a6e2 100644 --- a/web/src/controller/coaching_session_series_controller.rs +++ b/web/src/controller/coaching_session_series_controller.rs @@ -224,6 +224,7 @@ pub async fn delete( ), responses( (status = 204, description = "Invite resent"), + (status = 400, description = "Missing or unsupported API version header"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden — not the coach on the relationship"), (status = 404, description = "Not found"), diff --git a/web/src/controller/resend_invite_tests.rs b/web/src/controller/resend_invite_tests.rs index 04c2e726c..bc2666cad 100644 --- a/web/src/controller/resend_invite_tests.rs +++ b/web/src/controller/resend_invite_tests.rs @@ -11,19 +11,20 @@ use axum_login::{ }; use base64::{engine::general_purpose::STANDARD, Engine}; use chrono::{NaiveDateTime, Utc}; -use domain::user::Backend; -use domain::{ - actions, coaching_relationships, coaching_session_series, coaching_session_topics, - coaching_sessions, goals, organizations, user_roles, users, Id, -}; use mockito::{Mock, Server, ServerGuard}; use password_auth::generate_hash; use sea_orm::{prelude::DateTimeWithTimeZone, DatabaseBackend, MockDatabase}; -use service::config::Config; use time::Duration; use tower::ServiceExt; use tower_sessions::Expiry; +use domain::user::Backend; +use domain::{ + actions, coaching_relationships, coaching_session_series, coaching_session_topics, + coaching_sessions, goals, organizations, user_roles, users, Id, +}; +use service::config::Config; + use crate::controller::user_session_controller; use crate::router::{coaching_session_series_routes, coaching_sessions_routes}; use crate::AppState; @@ -43,9 +44,10 @@ fn start_in(days: i64) -> NaiveDateTime { .unwrap() } -/// One resend `POST`: who logs in, where it goes, and the loads it will make. +/// One resend `POST`: who logs in (`None` sends no cookie), where it goes, and the loads +/// it will make. struct Call { - caller: String, + caller: Option, path: String, db: MockDatabase, } @@ -179,12 +181,21 @@ impl Fixture { .append_query_results([vec![row.clone()]]) .append_query_results([vec![row]]); Call { - caller: caller.email.clone(), + caller: Some(caller.email.clone()), path, db, } } + /// No login, so no loads. + fn anonymous(path: String) -> Call { + Call { + caller: None, + path, + db: MockDatabase::new(DatabaseBackend::Postgres), + } + } + /// The loads `InviteParties::load` makes. fn parties(&self, db: MockDatabase) -> MockDatabase { db.append_query_results([vec![self.relationship.clone()]]) @@ -351,17 +362,17 @@ async fn resend(call: Call, coachee_status: Option) -> Outcome { .merge(coaching_session_series_routes(app_state)) .layer(AuthManagerLayerBuilder::new(Backend::new(&db), session_layer).build()); - let form = format!("email={}&password=password123", call.caller); - let login = Request::post("/login") - .header("content-type", "application/x-www-form-urlencoded") - .body(Body::from(form)) - .unwrap(); - let login = app.clone().oneshot(login).await.unwrap(); - let request = Request::post(call.path) - .header("cookie", login.headers()["set-cookie"].clone()) - .header("x-version", "1.0.0") - .body(Body::empty()) - .unwrap(); + let mut request = Request::post(call.path).header("x-version", "1.0.0"); + if let Some(caller) = call.caller { + let form = format!("email={caller}&password=password123"); + let login = Request::post("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(form)) + .unwrap(); + let login = app.clone().oneshot(login).await.unwrap(); + request = request.header("cookie", login.headers()["set-cookie"].clone()); + } + let request = request.body(Body::empty()).unwrap(); let response = app.oneshot(request).await.unwrap(); let status = response.status(); @@ -427,6 +438,42 @@ async fn refusals_send_nothing_and_bump_nothing() { } } +/// No cookie is a 401 and an unknown id a 404; neither sends nor bumps. +#[tokio::test] +async fn unauthenticated_and_unknown_send_nothing_and_bump_nothing() { + let f = Fixture::new(); + let series_path = format!("/coaching_session_series/{}/resend_invite", Id::new_v4()); + let session_path = format!("/coaching_sessions/{}/resend_invite", Id::new_v4()); + + let cases = [ + ( + Fixture::anonymous(series_path.clone()), + StatusCode::UNAUTHORIZED, + ), + ( + Fixture::anonymous(session_path.clone()), + StatusCode::UNAUTHORIZED, + ), + ( + f.login(&f.coach, series_path) + .then(|db| db.append_query_results([Vec::::new()])), + StatusCode::NOT_FOUND, + ), + ( + f.login(&f.coach, session_path).then(|db| { + db.append_query_results([Vec::<(Session, coaching_relationships::Model)>::new()]) + }), + StatusCode::NOT_FOUND, + ), + ]; + + for (case, (call, status)) in cases.into_iter().enumerate() { + let out = resend(call, None).await; + assert_eq!(out.status, status, "case {case}"); + assert!(out.sequence_updates.is_empty(), "case {case}"); + } +} + #[tokio::test] async fn series_resend_bumps_the_series_and_raises_its_members() { let f = Fixture::new();