Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 71 additions & 1 deletion domain/src/coaching_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<NaiveDateTime>, 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.
///
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions domain/src/coaching_session_series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<coaching_sessions::Model> =
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<coaching_sessions::Model> =
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,
Expand Down
Loading
Loading