@@ -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.
11751198async 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