Skip to content

Commit c0be6bb

Browse files
authored
Merge pull request #386 from refactor-group/fix/legacy-series-sessions-still-notify
Still notify participants when a legacy series occurrence cannot be addressed
2 parents ffa3168 + e0dd853 commit c0be6bb

3 files changed

Lines changed: 272 additions & 120 deletions

File tree

domain/src/emails.rs

Lines changed: 99 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ async fn send_session_email_to_recipient(
658658
to: &Recipient<'_>,
659659
session: &coaching_sessions::Model,
660660
organization: &organizations::Model,
661-
ics_body: &str,
661+
ics_body: Option<&str>,
662662
reschedule: Option<&RescheduleVars>,
663663
) -> Result<(), Error> {
664664
let recipient = to.user;
@@ -684,7 +684,7 @@ async fn send_session_email_to_recipient(
684684
.add_variable("session_duration", session_duration.as_str())
685685
.add_variable("session_url", session_url.as_str())
686686
.add_optional_variable("session_or_series", reschedule.map(|r| r.session_or_series))
687-
.add_ics_attachment(ics_body, &ical::Method::Request);
687+
.add_optional_ics_attachment(ics_body, &ical::Method::Request);
688688

689689
// The reschedule template declares both keys, so they ship together or not at all.
690690
let email_request = match reschedule {
@@ -831,21 +831,27 @@ async fn send_single_session_invite_email<N: EmailNotification>(
831831
});
832832

833833
let dtstamp = chrono::Utc::now().naive_utc();
834-
// A session inside a series is addressed as an override of its occurrence.
835-
let ics_body = match session.coaching_session_series_id {
836-
Some(series_id) => build_occurrence_reschedule_ics(
837-
coach,
838-
coachee,
839-
session,
840-
series_id,
841-
organization,
842-
description,
843-
dtstamp,
844-
)?,
845-
None => {
846-
build_session_invite_ics(coach, coachee, session, organization, description, dtstamp)?
847-
}
848-
};
834+
// A session inside a series is addressed as an override of its occurrence. One that
835+
// predates `ical_recurrence_id` has no such address, so it goes out without an
836+
// attachment rather than not going out at all.
837+
let ics_body = build_session_ics(
838+
session,
839+
description,
840+
|series_id, description| {
841+
build_occurrence_reschedule_ics(
842+
coach,
843+
coachee,
844+
session,
845+
series_id,
846+
organization,
847+
description,
848+
dtstamp,
849+
)
850+
},
851+
|description| {
852+
build_session_invite_ics(coach, coachee, session, organization, description, dtstamp)
853+
},
854+
)?;
849855

850856
// Email to coachee: "Your coach, ... has a session with you"
851857
if let Err(e) = send_session_email_to_recipient(
@@ -857,7 +863,7 @@ async fn send_single_session_invite_email<N: EmailNotification>(
857863
},
858864
session,
859865
organization,
860-
&ics_body,
866+
ics_body.as_deref(),
861867
reschedule,
862868
)
863869
.await
@@ -879,7 +885,7 @@ async fn send_single_session_invite_email<N: EmailNotification>(
879885
},
880886
session,
881887
organization,
882-
&ics_body,
888+
ics_body.as_deref(),
883889
reschedule,
884890
)
885891
.await
@@ -1027,14 +1033,6 @@ pub async fn notify_session_scheduled(
10271033
config: &Config,
10281034
session: &coaching_sessions::Model,
10291035
) {
1030-
if lacks_occurrence_address(session) {
1031-
warn!(
1032-
"Skipping scheduled invite for session {}: series member has no ical_recurrence_id",
1033-
session.id
1034-
);
1035-
return;
1036-
}
1037-
10381036
let result: Result<(), Error> = async {
10391037
let relationship =
10401038
coaching_relationship::find_by_id(db, session.coaching_relationship_id).await?;
@@ -1054,10 +1052,43 @@ pub async fn notify_session_scheduled(
10541052
}
10551053
}
10561054

1057-
/// True when a session belongs to a series but predates `ical_recurrence_id`, so no
1058-
/// valid `RECURRENCE-ID` exists. Sending anything would address the wrong occurrence.
1059-
fn lacks_occurrence_address(session: &coaching_sessions::Model) -> bool {
1060-
session.coaching_session_series_id.is_some() && session.ical_recurrence_id.is_none()
1055+
/// The invite for one session, or `None` when its occurrence cannot be addressed.
1056+
///
1057+
/// A session inside a series is addressed as an override of its occurrence; a standalone
1058+
/// one by its own `UID`. A series member materialized before `ical_recurrence_id` existed
1059+
/// has neither, so it goes out with no attachment rather than not going out at all.
1060+
///
1061+
/// `description` is passed through to whichever builder runs, so it moves exactly once.
1062+
fn build_session_ics(
1063+
session: &coaching_sessions::Model,
1064+
description: String,
1065+
build_occurrence: impl FnOnce(Id, String) -> Result<String, Error>,
1066+
build_standalone: impl FnOnce(String) -> Result<String, Error>,
1067+
) -> Result<Option<String>, Error> {
1068+
match (
1069+
session.coaching_session_series_id,
1070+
session.ical_recurrence_id,
1071+
) {
1072+
(Some(series_id), Some(_)) => build_occurrence(series_id, description).map(Some),
1073+
(Some(_), None) => {
1074+
warn_unaddressable(session);
1075+
Ok(None)
1076+
}
1077+
(None, _) => build_standalone(description).map(Some),
1078+
}
1079+
}
1080+
1081+
/// A series member that predates `ical_recurrence_id` has no valid `RECURRENCE-ID`, so no
1082+
/// invite can address its occurrence. The email still goes out; only the attachment is
1083+
/// withheld. These sessions were materialized before invites existed, so no calendar holds
1084+
/// an event for them and a `CANCEL` or update naming the series `UID` would act on the
1085+
/// wrong instance.
1086+
fn warn_unaddressable(session: &coaching_sessions::Model) {
1087+
warn!(
1088+
"Sending email without an invite for session {}: series member predates \
1089+
ical_recurrence_id, so its occurrence cannot be addressed",
1090+
session.id
1091+
);
10611092
}
10621093

10631094
/// Orchestrate sending session-rescheduled emails (best-effort).
@@ -1073,14 +1104,6 @@ pub async fn notify_session_rescheduled(
10731104
session: &coaching_sessions::Model,
10741105
previous_start: NaiveDateTime,
10751106
) {
1076-
if lacks_occurrence_address(session) {
1077-
warn!(
1078-
"Skipping reschedule invite for session {}: series member has no ical_recurrence_id",
1079-
session.id
1080-
);
1081-
return;
1082-
}
1083-
10841107
let result: Result<(), Error> = async {
10851108
let relationship =
10861109
coaching_relationship::find_by_id(db, session.coaching_relationship_id).await?;
@@ -1174,13 +1197,12 @@ fn build_occurrence_cancel_ics(
11741197
/// variables than the invite sends: no `session_url` (the row is gone) and no duration.
11751198
async fn send_session_cancelled_email_to_recipient(
11761199
email_config: &ResolvedEmailConfig,
1177-
recipient: &users::Model,
1178-
other_user: &users::Model,
1179-
other_user_role: &str,
1200+
to: &Recipient<'_>,
11801201
session: &coaching_sessions::Model,
11811202
organization: &organizations::Model,
1182-
ics_body: &str,
1203+
ics_body: Option<&str>,
11831204
) -> Result<(), Error> {
1205+
let recipient = to.user;
11841206
let (session_date, session_time) = format_session_date_time(session.date, &recipient.timezone);
11851207

11861208
let email_request = SendEmailRequestBuilder::new()
@@ -1191,13 +1213,13 @@ async fn send_session_cancelled_email_to_recipient(
11911213
)
11921214
.template_id(&email_config.template_id)
11931215
.add_variable("first_name", recipient.first_name.as_str())
1194-
.add_variable("other_user_first_name", other_user.first_name.as_str())
1195-
.add_variable("other_user_last_name", other_user.last_name.as_str())
1196-
.add_variable("other_user_role", other_user_role)
1216+
.add_variable("other_user_first_name", to.other_user.first_name.as_str())
1217+
.add_variable("other_user_last_name", to.other_user.last_name.as_str())
1218+
.add_variable("other_user_role", to.other_user_role)
11971219
.add_variable("organization_name", organization.name.as_str())
11981220
.add_variable("session_date", session_date.as_str())
11991221
.add_variable("session_time", session_time.as_str())
1200-
.add_ics_attachment(ics_body, &ical::Method::Cancel)
1222+
.add_optional_ics_attachment(ics_body, &ical::Method::Cancel)
12011223
.build()
12021224
.await?;
12031225

@@ -1222,34 +1244,35 @@ async fn send_session_cancelled_email(
12221244

12231245
let dtstamp = chrono::Utc::now().naive_utc();
12241246
// A session inside a series is addressed as an override of its occurrence.
1225-
let ics_body = match session.coaching_session_series_id {
1226-
Some(series_id) => build_occurrence_cancel_ics(
1227-
coach,
1228-
coachee,
1229-
session,
1230-
series_id,
1231-
organization,
1232-
SESSION_CANCELLED_DESCRIPTION.to_string(),
1233-
dtstamp,
1234-
)?,
1235-
None => build_session_cancel_ics(
1236-
coach,
1237-
coachee,
1238-
session,
1239-
organization,
1240-
SESSION_CANCELLED_DESCRIPTION.to_string(),
1241-
dtstamp,
1242-
)?,
1243-
};
1247+
let ics_body = build_session_ics(
1248+
session,
1249+
SESSION_CANCELLED_DESCRIPTION.to_string(),
1250+
|series_id, description| {
1251+
build_occurrence_cancel_ics(
1252+
coach,
1253+
coachee,
1254+
session,
1255+
series_id,
1256+
organization,
1257+
description,
1258+
dtstamp,
1259+
)
1260+
},
1261+
|description| {
1262+
build_session_cancel_ics(coach, coachee, session, organization, description, dtstamp)
1263+
},
1264+
)?;
12441265

12451266
if let Err(e) = send_session_cancelled_email_to_recipient(
12461267
&email_config,
1247-
coachee,
1248-
coach,
1249-
"coach",
1268+
&Recipient {
1269+
user: coachee,
1270+
other_user: coach,
1271+
other_user_role: "coach",
1272+
},
12501273
session,
12511274
organization,
1252-
&ics_body,
1275+
ics_body.as_deref(),
12531276
)
12541277
.await
12551278
{
@@ -1261,12 +1284,14 @@ async fn send_session_cancelled_email(
12611284

12621285
if let Err(e) = send_session_cancelled_email_to_recipient(
12631286
&email_config,
1264-
coach,
1265-
coachee,
1266-
"coachee",
1287+
&Recipient {
1288+
user: coach,
1289+
other_user: coachee,
1290+
other_user_role: "coachee",
1291+
},
12671292
session,
12681293
organization,
1269-
&ics_body,
1294+
ics_body.as_deref(),
12701295
)
12711296
.await
12721297
{
@@ -1292,14 +1317,6 @@ pub async fn notify_session_cancelled(
12921317
if session.date < chrono::Utc::now().naive_utc() {
12931318
return;
12941319
}
1295-
if lacks_occurrence_address(session) {
1296-
warn!(
1297-
"Skipping cancellation for session {}: series member has no ical_recurrence_id",
1298-
session.id
1299-
);
1300-
return;
1301-
}
1302-
13031320
let result: Result<(), Error> = async {
13041321
let relationship =
13051322
coaching_relationship::find_by_id(db, session.coaching_relationship_id).await?;

0 commit comments

Comments
 (0)