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 c8efeb763..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; @@ -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. /// @@ -446,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..88cf3a246 100644 --- a/domain/src/coaching_session_series.rs +++ b/domain/src/coaching_session_series.rs @@ -332,6 +332,65 @@ 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. + // 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, + 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 fd0cf7fce..db0d5d7ef 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. @@ -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,117 @@ 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. +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 master_url = master.location_url.clone(); + let overrides = slotted.iter().filter_map(|(slot, member)| { + member + .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), + 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) +} + +/// 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 @@ -1622,9 +1733,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, @@ -1669,17 +1781,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 { @@ -1700,6 +1819,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( @@ -1722,9 +1843,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. @@ -1747,11 +1870,91 @@ async fn send_recurring_sessions_scheduled_email( replaced: &[], }, organization, + 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. +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 +} + +/// 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 @@ -1775,13 +1978,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). @@ -1916,6 +2123,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..9b944fefc 100644 --- a/domain/src/emails_tests.rs +++ b/domain/src/emails_tests.rs @@ -2444,6 +2444,218 @@ 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, 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, + ..create_test_series() + } +} + +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() +} + +/// 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(); + (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()), + ..create_test_series_session(series.id, slot) + }) + .collect() +} + +/// The members with each `(slot, sequence)` in `moved` a day later at that sequence, and +/// the `deleted` slots gone. +fn edit_members( + series: &coaching_session_series::Model, + deleted: &[usize], + moved: &[(usize, i32)], +) -> 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, + 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 = at(2026, 9, 1, 12); + let ics = build_series_snapshot_ics(&coach, &coachee, series, members, "desc".into(), dtstamp) + .unwrap(); + unfold(&ics) +} + +/// 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 ics = edited_snapshot(snapshot_start(), 1, &[], &[]); + assert_snapshot(&ics, (FIRST, 1), &[], &[]); +} + +#[test] +fn snapshot_deleted_slot_becomes_exdate() { + let ics = edited_snapshot(snapshot_start(), 1, &[3], &[]); + assert_snapshot(&ics, (FIRST, 1), &["20261006T140000"], &[]); +} + +#[test] +fn snapshot_moved_slot_becomes_override() { + let ics = edited_snapshot(snapshot_start(), 1, &[], &[(2, 4)]); + let moved = ("20260929T140000", "20260930T140000", 4); + assert_snapshot(&ics, (FIRST, 1), &[], &[moved]); +} + +#[test] +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_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_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)]); +} + +/// Google drops an override whose SEQUENCE trails its master; a higher one is kept. +#[test] +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)]); +} + +/// 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); + let members = snapshot_members(&series); + let stray = |recurrence_id| coaching_sessions::Model { + coaching_session_series_id: Some(series.id), + ical_recurrence_id: recurrence_id, + 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 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), snapshot(&series, &members)); +} + #[cfg(feature = "mock")] #[tokio::test] async fn test_send_recurring_sessions_scheduled_email_personalization() { @@ -3151,6 +3363,103 @@ async fn test_rescheduling_a_series_clears_the_standalone_events_it_replaced() { ); } +/// The two invites a series sends. +#[cfg(feature = "mock")] +enum SeriesSend { + Resend, + Scheduled, +} + +/// 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 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 (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 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); + } + + 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()) +} + +#[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")); + 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 (delivered, _) = send_edited_series(SeriesSend::Resend, 400).await; + + 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 (_, attachments) = send_edited_series(SeriesSend::Scheduled, 200).await; + + assert_eq!(attachments.len(), 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 /// 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 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..589f85fc6 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,70 @@ fn override_instance_never_carries_an_rrule() { assert!(out.contains("RECURRENCE-ID;TZID=America/New_York:20260908T150000")); } +/// 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(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 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 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 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 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); + 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 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. fn unfold(s: &str) -> String { s.replace("\r\n ", "").replace("\r\n\t", "") diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 693a6adc0..dcdb4e15d 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` @@ -782,6 +803,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 +857,7 @@ pub struct IncludeOptions { pub goal: bool, pub agreements: bool, pub topics: bool, + pub series: bool, } impl IncludeOptions { @@ -839,6 +872,7 @@ impl IncludeOptions { goal: false, agreements: false, topics: false, + series: false, } } @@ -1022,6 +1056,7 @@ struct RelatedData { goals: HashMap>, agreements: HashMap, topics: HashMap>, + series_last_dates: HashMap, } /// Load all requested related data in efficient batches @@ -1078,6 +1113,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 +1277,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 +1355,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 +1379,7 @@ fn assemble_enriched_session( viewer_last_viewed_at, display_title, topics, + series, } } @@ -1309,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 @@ -1847,6 +1936,40 @@ mod tests { Ok(()) } + #[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: floor, + ..series_session(Some(series_id), naive("2026-06-01 10:00:00")) + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![raised.clone()]]) + .into_connection(); + + let result = raise_series_ical_sequence(&db, series_id, floor).await?; + assert_eq!(result, vec![raised]); + + let statements: Vec = db + .into_transaction_log() + .iter() + .flat_map(|txn| txn.statements()) + .cloned() + .collect(); + assert_eq!(statements.len(), 1, "{statements:?}"); + 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(()) + } + #[tokio::test] async fn find_by_id_returns_a_single_record() -> Result<(), Error> { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); @@ -2611,6 +2734,7 @@ mod tests { goal: false, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2623,6 +2747,7 @@ mod tests { goal: false, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_err()); } @@ -2635,6 +2760,7 @@ mod tests { goal: true, agreements: false, topics: false, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2647,6 +2773,7 @@ mod tests { goal: true, agreements: true, topics: true, + series: false, }; assert!(includes.validate().is_ok()); } @@ -2803,4 +2930,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/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..862593775 100644 --- a/web/src/controller/coaching_session_controller.rs +++ b/web/src/controller/coaching_session_controller.rs @@ -286,6 +286,39 @@ 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 = 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"), + (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..7c540a6e2 100644 --- a/web/src/controller/coaching_session_series_controller.rs +++ b/web/src/controller/coaching_session_series_controller.rs @@ -213,6 +213,36 @@ 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 = 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"), + (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..bc2666cad --- /dev/null +++ b/web/src/controller/resend_invite_tests.rs @@ -0,0 +1,531 @@ +//! `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 mockito::{Mock, Server, ServerGuard}; +use password_auth::generate_hash; +use sea_orm::{prelude::DateTimeWithTimeZone, DatabaseBackend, MockDatabase}; +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; + +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 (`None` sends no cookie), where it goes, and the loads +/// it will make. +struct Call { + caller: Option, + 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, building +/// the calls each route makes. +struct Fixture { + coach: users::Model, + coachee: users::Model, + organization: organizations::Model, + relationship: coaching_relationships::Model, +} + +impl Fixture { + fn new() -> Self { + 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(), + updated_at: now(), + 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(), + updated_at: now(), + }; + Self { + coach, + coachee, + organization, + relationship, + } + } + + fn series(&self, start_at: NaiveDateTime) -> Series { + Series { + 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: 3, + created_by_user_id: self.coach.id, + created_at: now(), + updated_at: now(), + } + } + + fn session(&self, date: NaiveDateTime) -> Session { + Session { + 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(), + updated_at: now(), + hydrated_at: None, + notice_given_at: now(), + } + } + + /// One member per slot of `series`, each in the slot it was created for. + 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| 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: 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()]]) + .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![]]) + }) + } +} + +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, +} + +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 +} + +/// 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={}", 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()), + 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 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(AuthManagerLayerBuilder::new(Backend::new(&db), session_layer).build()); + + 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(); + 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; + } + let log = Arc::try_unwrap(db).expect("the app has been dropped"); + let sequence_updates = log + .into_transaction_log() + .iter() + .flat_map(|txn| txn.statements()) + .map(|stmt| stmt.sql.clone()) + .filter(|sql| sql.starts_with("UPDATE") && sql.contains("ical_sequence")) + .collect(); + 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 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}"); + } +} + +/// 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(); + 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 out = resend(f.series_sent(&series, rows), Some(200)).await; + + 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 series_resend_reports_an_unreached_coachee() { + let f = Fixture::new(); + let series = f.series(start_in(7)); + + let out = resend(f.series_sent(&series, f.members(&series)), Some(400)).await; + + assert_eq!(out.status, StatusCode::BAD_GATEWAY); +} + +#[tokio::test] +async fn session_resend_sends_a_legacy_member_standalone() { + let f = Fixture::new(); + let session = Session { + coaching_session_series_id: Some(Id::new_v4()), + ..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); + 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_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")); +} 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]); + } } 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) }