is_cohort_only() ) { self::authorize_cohort( $inv->target_cohort_id, $actor ); return; } self::authorize_course( $inv->course_id, $actor ); if ( $inv->target_cohort_id > 0 && null !== Services::cohort_repo()->get( $inv->target_cohort_id ) ) { self::authorize_cohort( $inv->target_cohort_id, $actor ); } } public static function authorize_cohort( int $cohort_id, Actor $actor ): Cohort { $cohort = $cohort_id > 0 ? Services::cohort_repo()->get( $cohort_id ) : null; if ( null === $cohort ) { DomainError::raise( ErrorCode::NOT_FOUND ); } if ( $actor->is_trusted() ) { return $cohort; } if ( $actor->is_learner() || $actor->user_id <= 0 || ! OrgScope::for_user( $actor->user_id )->can_manage( $cohort->org_id ) ) { DomainError::raise( ErrorCode::FORBIDDEN ); } return $cohort; } public static function authorize_course( int $course_id, Actor $actor ): void { $course = $course_id > 0 ? Services::course_repo()->get( $course_id ) : null; if ( null === $course ) { DomainError::raise( ErrorCode::NOT_FOUND ); } if ( $actor->is_trusted() ) { return; } Authorize::edit_post( $actor, $course->home_post_id ); } public static function may_manage( int $course_id, int $user_id, int $cohort_id = 0 ): bool { if ( $course_id <= 0 && $cohort_id > 0 ) { $cohort = Services::cohort_repo()->get( $cohort_id ); return null !== $cohort && $user_id > 0 && OrgScope::for_user( $user_id )->can_manage( $cohort->org_id ); } $course = $course_id > 0 ? Services::course_repo()->get( $course_id ) : null; return null !== $course && $user_id > 0 && user_can( $user_id, 'edit_post', $course->home_post_id ); } public static function may_manage_invitation( Invitation $inv, int $user_id ): bool { if ( $inv->is_cohort_only() ) { return self::may_manage( 0, $user_id, $inv->target_cohort_id ); } if ( ! self::may_manage( $inv->course_id, $user_id ) ) { return false; } $cohort = $inv->target_cohort_id > 0 ? Services::cohort_repo()->get( $inv->target_cohort_id ) : null; return null === $cohort || OrgScope::for_user( $user_id )->can_manage( $cohort->org_id ); } public static function create( int $course_id, int $unit_id, array $data, Actor $actor ): Invitation { $cohort_id = max( 0, (int) ( $data['target_cohort_id'] ?? 0 ) ); if ( $course_id <= 0 && $cohort_id > 0 ) { self::authorize_cohort( $cohort_id, $actor ); if ( $unit_id > 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'unit' ) ); } } else { self::authorize_course( $course_id, $actor ); Target::of( $course_id, $unit_id ); } if ( ! isset( $data['occurrence_id'] ) || (int) $data['occurrence_id'] <= 0 ) { $data['occurrence_id'] = self::next_occurrence( $course_id, $unit_id ); } $clean = self::clean( $course_id, $unit_id, $data, null, $actor ); $id = (int) Transaction::run( static function () use ( $course_id, $unit_id, $clean, $actor ): int { self::hold_targets( $clean, null ); $now = Clock::now(); Db::query( 'INSERT INTO %i (kind, course_id, unit_id, occurrence_id, template_id, version, attachment_ids, rsvp, rsvp_until, yes_action, places, state, subject, body, lang, created_by, created_at, updated_at, target_cohort_id) VALUES (%d, %d, %d, %d, %d, 1, %s, %d, NULLIF( %s, %s ), %d, %d, %d, %s, %s, %s, %d, %s, %s, %d)', Db::table( 'invitations' ), InvitationKind::RSVP->value, $course_id, $unit_id, $clean['occurrence_id'], $clean['template_id'], (string) wp_json_encode( $clean['attachment_ids'] ), $clean['rsvp'], (string) $clean['rsvp_until'], '', $clean['yes_action'], $clean['places'], InvitationState::DRAFT->value, $clean['subject'], $clean['body'], Names::default_language(), max( 0, $actor->user_id ), $now, $now, $clean['target_cohort_id'] ); return Db::insert_id(); } ); $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::DB ); InvitationTexts::register_strings( $inv ); do_action( 'rooster_lms_invitation_created', $inv->id, max( 0, $actor->user_id ) ); return $inv; } public static function defaults( int $course_id, Actor $actor ): array { $rsvp = max( 0, min( 7, (int) \RoosterLMS\Core\Settings::get( 'inv_rsvp' ) ) ); $action = match ( (string) \RoosterLMS\Core\Settings::get( 'inv_yes_action' ) ) { 'register' => YesAction::REGISTER, 'enrol' => YesAction::ENROL, default => YesAction::NONE, }; if ( YesAction::ENROL === $action && ( $course_id <= 0 || ( ! RsvpService::is_free( $course_id ) && ! Audiences::site_admin( $actor ) ) ) ) { $action = YesAction::NONE; } return array( 'rsvp' => $rsvp, 'yes_action' => $action->value, ); } public static function update( int $id, array $data, Actor $actor ): Invitation { $inv = self::editable( $id, $actor ); $new = self::clean( $inv->course_id, $inv->unit_id, $data, $inv, $actor ); $texts = $new['subject'] !== $inv->subject || $new['body'] !== $inv->body; $bump = '' !== $inv->sent_at && ( $texts || $new['template_id'] !== $inv->template_id || $new['attachment_ids'] !== $inv->attachment_ids ); global $wpdb; $sql = SqlPart::of( 'UPDATE %i SET occurrence_id = %d, template_id = %d, attachment_ids = %s, rsvp = %d, yes_action = %d, target_cohort_id = %d, places = %d, subject = %s, body = %s, version = version + %d, updated_at = %s, ', Db::table( 'invitations' ), $new['occurrence_id'], $new['template_id'], (string) wp_json_encode( $new['attachment_ids'] ), $new['rsvp'], $new['yes_action'], $new['target_cohort_id'], $new['places'], $new['subject'], $new['body'], $bump ? 1 : 0, Clock::now() )->add( null === $new['rsvp_until'] ? SqlPart::of( 'rsvp_until = NULL' ) : SqlPart::of( 'rsvp_until = %s', $new['rsvp_until'] ) )->add( SqlPart::of( ' WHERE id = %d', $id ) ); Transaction::run( static function () use ( $sql, $new, $inv ): void { self::hold_targets( $new, $inv ); Db::query( $sql ); } ); InvitationRepository::forget( $id ); $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); if ( $texts ) { InvitationTexts::register_strings( $inv ); } if ( $bump ) { do_action( 'rooster_lms_invitation_version', $inv->id, $inv->version, $actor->user_id ); } return $inv; } public static function cancel( int $id, Actor $actor ): Invitation { $inv = self::editable( $id, $actor ); Db::query( 'UPDATE %i SET state = %d, updated_at = %s WHERE id = %d', Db::table( 'invitations' ), InvitationState::CANCELLED->value, Clock::now(), $inv->id ); Sender::unschedule( $inv->id ); InvitationRepository::forget( $inv->id ); do_action( 'rooster_lms_invitation_cancelled', $inv->id, max( 0, $actor->user_id ) ); return InvitationRepository::get( $inv->id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); } public static function unschedule( int $id, Actor $actor ): Invitation { $inv = self::editable( $id, $actor ); if ( InvitationState::SCHEDULED !== $inv->state ) { return $inv; } Db::query( 'UPDATE %i SET state = %d, scheduled_at = NULL, updated_at = %s WHERE id = %d AND state = %d', Db::table( 'invitations' ), '' === $inv->sent_at ? InvitationState::DRAFT->value : InvitationState::SENT->value, Clock::now(), $inv->id, InvitationState::SCHEDULED->value ); InvitationRepository::forget( $inv->id ); do_action( 'rooster_lms_invitation_unscheduled', $inv->id, max( 0, $actor->user_id ) ); return InvitationRepository::get( $inv->id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); } public static function remind( int $id, Actor $actor, int $after = 0 ): array { $inv = self::editable( $id, $actor ); if ( '' === $inv->sent_at || ! $inv->asks() ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'not_sent' ) ); } return Sender::remind_batch( $inv, $after ); } public static function add_recipients( int $id, string $source, array $args, Actor $actor, bool $dry_run ): array { $inv = self::editable( $id, $actor ); $out = Audiences::add( $inv, $source, $args, $actor, $dry_run ); if ( ! $dry_run && $out['added'] > 0 ) { do_action( 'rooster_lms_invitation_recipients_added', $inv->id, $source, $out['added'], $actor->user_id ); } return $out; } public static function preview( int $id, Actor $actor ): array { $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); self::authorize( $inv, $actor ); return SendRules::preview( $inv ); } public static function send( int $id, Actor $actor, string $scheduled_at = '' ): array { $inv = self::editable( $id, $actor ); if ( '' !== $scheduled_at ) { $at = self::utc( $scheduled_at ); if ( null === $at || true !== apply_filters( 'rooster_lms_invitation_can_schedule', false, $inv->id, (string) $at, $actor->user_id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'scheduled_at' ) ); } global $wpdb; Db::query( 'UPDATE %i SET state = %d, scheduled_at = %s, updated_at = %s WHERE id = %d', Db::table( 'invitations' ), InvitationState::SCHEDULED->value, $at, Clock::now(), $inv->id ); InvitationRepository::forget( $inv->id ); do_action( 'rooster_lms_invitation_scheduled', $inv->id, (string) $at, $actor->user_id ); return array( 'state' => InvitationState::SCHEDULED->slug(), 'to_send' => SendRules::preview( $inv )['to_send'], 'scheduled_at' => (string) $at, ); } if ( ! \RoosterLMS\Messages\TemplateRepository::key_enabled( \RoosterLMS\Notify\NotificationKey::INVITATION ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'disabled' ) ); } $preview = SendRules::preview( $inv ); if ( $preview['to_send'] <= 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'audience' ) ); } global $wpdb; $now = Clock::now(); Db::query( 'UPDATE %i SET state = %d, sent_at = COALESCE( sent_at, %s ), scheduled_at = NULL, updated_at = %s WHERE id = %d', Db::table( 'invitations' ), InvitationState::SENDING->value, $now, $now, $inv->id ); InvitationRepository::forget( $inv->id ); self::mark_used(); Sender::enqueue( $inv->id, 0 ); return array( 'state' => InvitationState::SENDING->slug(), 'to_send' => $preview['to_send'], 'scheduled_at' => '', ); } public static function report( int $id, Actor $actor ): array { $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); self::authorize( $inv, $actor ); return array( 'invitation' => $inv->to_array(), 'counts' => InvitationRepository::counts( $inv ), ); } public const USED_OPTION = 'rooster_lms_invitations_used'; public static function used(): bool { $all = wp_load_alloptions(); return ! empty( $all[ self::USED_OPTION ] ); } public static function mark_used(): void { if ( ! self::used() ) { update_option( self::USED_OPTION, 1, true ); } } private static function editable( int $id, Actor $actor ): Invitation { $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); self::authorize( $inv, $actor ); if ( InvitationKind::RSVP !== $inv->kind ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'kind' ) ); } if ( $inv->is_cancelled() ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'cancelled' ) ); } return $inv; } private static function clean( int $course_id, int $unit_id, array $data, ?Invitation $old, Actor $actor ): array { $defaults = null === $old ? self::defaults( $course_id, $actor ) : array(); $out = array( 'occurrence_id' => null === $old ? 0 : $old->occurrence_id, 'template_id' => null === $old ? 0 : $old->template_id, 'subject' => null === $old ? '' : $old->subject, 'body' => null === $old ? '' : $old->body, 'rsvp' => null === $old ? (int) $defaults['rsvp'] : $old->rsvp, 'rsvp_until' => null === $old || '' === $old->rsvp_until ? null : $old->rsvp_until, 'yes_action' => null === $old ? (int) $defaults['yes_action'] : $old->yes_action->value, 'places' => null === $old ? 0 : $old->places, 'attachment_ids' => null === $old ? array() : $old->attachment_ids, 'target_cohort_id' => null === $old ? 0 : $old->target_cohort_id, ); if ( array_key_exists( 'target_cohort_id', $data ) && null !== $data['target_cohort_id'] ) { $out['target_cohort_id'] = self::target_cohort( $course_id, max( 0, (int) $data['target_cohort_id'] ), $old, $actor ); } if ( array_key_exists( 'occurrence_id', $data ) ) { list( $out['occurrence_id'], $out['occurrence_session'] ) = self::check_occurrence( $course_id, $unit_id, max( 0, (int) $data['occurrence_id'] ) ); } if ( array_key_exists( 'template_id', $data ) ) { $out['template_id'] = max( 0, (int) $data['template_id'] ); } if ( array_key_exists( 'subject', $data ) || array_key_exists( 'body', $data ) ) { $text = InvitationTexts::clean( $data['subject'] ?? $out['subject'], $data['body'] ?? $out['body'] ); $out['subject'] = $text['subject']; $out['body'] = $text['body']; } if ( array_key_exists( 'rsvp', $data ) ) { $bits = 0; foreach ( (array) $data['rsvp'] as $slug ) { $answer = is_string( $slug ) ? RsvpAnswer::from_slug( $slug ) : null; $bits |= null === $answer ? 0 : $answer->bit(); } $out['rsvp'] = $bits; } if ( array_key_exists( 'rsvp_until', $data ) ) { $raw = is_string( $data['rsvp_until'] ) ? trim( $data['rsvp_until'] ) : ''; if ( '' === $raw ) { $out['rsvp_until'] = null; } else { $out['rsvp_until'] = self::utc( $raw ) ?? DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'rsvp_until' ) ); } } if ( 0 !== $out['rsvp'] && null === $out['rsvp_until'] && $out['occurrence_id'] <= 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'rsvp_until' ) ); } if ( array_key_exists( 'yes_action', $data ) ) { $action = match ( is_string( $data['yes_action'] ) ? $data['yes_action'] : (string) (int) $data['yes_action'] ) { 'register', '1' => YesAction::REGISTER, 'enrol', '2' => YesAction::ENROL, 'join_cohort', '3' => YesAction::JOIN_COHORT, 'none', '0', '' => YesAction::NONE, default => DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'yes_action' ) ), }; if ( YesAction::ENROL === $action && $course_id <= 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'yes_action' ) ); } if ( YesAction::ENROL === $action && ( null === $old || YesAction::ENROL !== $old->yes_action ) && ! RsvpService::is_free( $course_id ) && ! Audiences::site_admin( $actor ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'yes_action' ) ); } $out['yes_action'] = $action->value; } if ( YesAction::JOIN_COHORT->value === $out['yes_action'] && $out['target_cohort_id'] <= 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'yes_action' ) ); } if ( $course_id <= 0 && $out['target_cohort_id'] <= 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'target_cohort' ) ); } if ( array_key_exists( 'places', $data ) ) { $places = (int) $data['places']; if ( $places < 0 || $places > self::MAX_PLACES ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'places' ) ); } $out['places'] = $places; } if ( array_key_exists( 'attachment_ids', $data ) ) { $ids = array_values( array_unique( array_filter( array_map( 'intval', (array) $data['attachment_ids'] ), static fn( int $id ): bool => $id > 0 ) ) ); if ( count( $ids ) > self::MAX_ATTACHMENTS ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'attachments' ) ); } foreach ( $ids as $attachment ) { $post = get_post( $attachment ); if ( ! $post instanceof \WP_Post || 'attachment' !== $post->post_type || ( ! $actor->is_trusted() && ! user_can( $actor->user_id, 'edit_post', $attachment ) ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'attachments' ) ); } if ( ProtectedFiles::is_protected( $attachment ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'attachment_protected' ) ); } } $out['attachment_ids'] = $ids; } return $out; } private static function target_cohort( int $course_id, int $cohort_id, ?Invitation $old, Actor $actor ): int { if ( $cohort_id <= 0 || ( null !== $old && $old->target_cohort_id === $cohort_id ) ) { return $cohort_id; } $cohort = self::authorize_cohort( $cohort_id, $actor ); if ( ! $cohort->is_active() || ( $course_id > 0 && ! $cohort->is_programme() && $cohort->course_id !== $course_id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'target_cohort' ) ); } if ( null !== $old && $old->created_by > 0 && $old->created_by !== $actor->user_id && ! OrgScope::for_user( $old->created_by )->can_manage( $cohort->org_id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'target_cohort_author' ) ); } return $cohort->id; } public static function funnel( int $id, Actor $actor ): array { $inv = InvitationRepository::get( $id ) ?? DomainError::raise( ErrorCode::NOT_FOUND ); self::authorize( $inv, $actor ); return self::funnel_of( $inv, InvitationRepository::counts( $inv ) ); } public static function funnel_of( Invitation $inv, array $counts ): array { $funnel = (array) ( $counts['funnel'] ?? array() ); $invited = (int) ( $funnel['invited'] ?? 0 ); $accepted = (int) ( $funnel['yes'] ?? 0 ); $declined = (int) ( $funnel['no'] ?? 0 ); $maybe = (int) ( $funnel['maybe'] ?? 0 ); $attended = (int) ( $funnel['attended'] ?? 0 ); $converted = (int) ( $funnel['converted'] ?? 0 ); $rate = static fn( int $part, int $whole ): float => $whole > 0 ? round( $part / $whole, 4 ) : 0.0; return array( 'invitation_id' => $inv->id, 'invited' => $invited, 'accepted' => $accepted, 'declined' => $declined, 'maybe' => $maybe, 'pending' => max( 0, $invited - $accepted - $declined - $maybe ), 'attended' => $attended, 'converted' => $converted, 'rates' => array( 'accept' => $rate( $accepted, $invited ), 'attend_of_accepted' => $rate( $converted, $accepted ), 'attend_of_invited' => $rate( $attended, $invited ), ), 'outcome_kind' => self::outcome_kind( $inv ), ); } public static function outcome_kind( Invitation $inv ): string { if ( YesAction::JOIN_COHORT === $inv->yes_action || $inv->is_cohort_only() ) { return InviteeOutcome::JOINED->slug(); } return $inv->occurrence_id > 0 ? InviteeOutcome::ATTENDED->slug() : InviteeOutcome::ENROLLED->slug(); } private static function hold_targets( array $fields, ?Invitation $old ): void { $occurrence = (int) $fields['occurrence_id']; if ( $occurrence > 0 && $occurrence !== (int) $old?->occurrence_id ) { $session = (int) ( $fields['occurrence_session'] ?? 0 ); if ( $session <= 0 || null === Db::row( 'SELECT id FROM %i WHERE id = %d LOCK IN SHARE MODE', Db::table( 'live_sessions' ), $session ) || null === Db::row( 'SELECT id FROM %i WHERE id = %d AND session_id = %d LOCK IN SHARE MODE', Db::table( 'live_occurrences' ), $occurrence, $session ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'occurrence' ) ); } } $template = (int) $fields['template_id']; if ( $template > 0 && $template !== (int) $old?->template_id && null === Db::row( 'SELECT id FROM %i WHERE id = %d LOCK IN SHARE MODE', Db::table( 'message_templates' ), $template ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'template' ) ); } $cohort = (int) $fields['target_cohort_id']; if ( $cohort > 0 && $cohort !== (int) $old?->target_cohort_id ) { $state = Db::value( 'SELECT state FROM %i WHERE id = %d LOCK IN SHARE MODE', Db::table( 'cohorts' ), $cohort ); if ( null === $state || Cohort::ACTIVE !== (int) $state ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'target_cohort' ) ); } } } private static function check_occurrence( int $course_id, int $unit_id, int $occurrence_id ): array { if ( $occurrence_id <= 0 ) { return array( 0, 0 ); } $o = Services::live_session_repo()->occurrence( $occurrence_id ); $s = null === $o ? null : Services::live_session_repo()->get( $o->session_id ); if ( null === $s || $s->course_id !== $course_id || ( $unit_id > 0 && $s->unit_id !== $unit_id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'occurrence' ) ); } return array( $occurrence_id, $s->id ); } private static function next_occurrence( int $course_id, int $unit_id ): int { if ( $unit_id <= 0 ) { return 0; } $ctx = LessonContext::for_unit( $unit_id, $course_id ); return null === $ctx || null === $ctx->occurrence ? 0 : $ctx->occurrence->id; } private static function utc( string $raw ): ?string { $raw = trim( str_replace( 'T', ' ', $raw ) ); if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $raw ) ) { return null; } $ts = strtotime( ( 16 === strlen( $raw ) ? $raw . ':00' : $raw ) . ' UTC' ); return false === $ts ? null : gmdate( 'Y-m-d H:i:s', $ts ); } }