$invitation_id, 'after' => $after, ); if ( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( self::HOOK, $args, self::GROUP ) ) { return; } as_enqueue_async_action( self::HOOK, $args, self::GROUP ); } public static function unschedule( int $invitation_id ): void { if ( ! function_exists( 'as_get_scheduled_actions' ) || ! function_exists( 'as_unschedule_action' ) || ! did_action( 'action_scheduler_init' ) || ! class_exists( '\ActionScheduler_Store' ) ) { return; } $actions = as_get_scheduled_actions( array( 'hook' => self::HOOK, 'group' => self::GROUP, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => 50, ) ); foreach ( is_array( $actions ) ? $actions : array() as $action ) { $args = $action instanceof \ActionScheduler_Action ? $action->get_args() : array(); if ( (int) ( $args['invitation'] ?? 0 ) === $invitation_id ) { as_unschedule_action( self::HOOK, $args, self::GROUP ); } } } public static function run( $invitation = 0, $after = 0 ): void { $out = self::run_batch( (int) $invitation, (int) $after ); if ( $out['more'] ) { self::enqueue( (int) $invitation, $out['last'] ); } } public static function run_batch( int $invitation_id, int $after = 0 ): array { $out = array( 'sent' => 0, 'failed' => 0, 'suppressed' => 0, 'capped' => 0, 'more' => false, 'last' => $after, ); $rows = self::candidates( $invitation_id, $after ); if ( array() === $rows ) { self::finish( $invitation_id ); return $out; } $inv = Invitation::from_row( self::invitation_columns( $rows[0] ) ); if ( InvitationState::SENDING !== $inv->state ) { return $out; } $cap = Audience::weekly_cap(); $expiry = RsvpService::token_expiry( $inv ); $now = Clock::now(); $send = array(); $states = array(); $tokens = array(); $logs = array(); $previous = array(); $default = Names::default_language(); foreach ( $rows as $row ) { $id = (int) $row['id']; $out['last'] = $id; $previous[ $id ] = array( null === $row['prev_sent_at'] ? null : (string) $row['prev_sent_at'], (int) $row['prev_version'] ); $user_id = (int) $row['user_id']; $address = $user_id > 0 ? (string) ( $row['user_email'] ?? '' ) : (string) $row['email']; if ( 1 === (int) $row['unsub'] || 0 === (int) $row['consent'] ) { $states[ $id ] = InviteeState::SUPPRESSED->value; $logs[] = array( $user_id, (string) $row['email_hash'], LogState::SUPPRESSED, 1 === (int) $row['unsub'] ? 'unsubscribed' : 'consent', '' ); ++$out['suppressed']; continue; } if ( $cap > 0 && (int) $row['recent'] >= $cap ) { ++$out['capped']; continue; } if ( '' === $address || false === is_email( $address ) || ( $user_id > 0 && null === $row['user_email'] ) ) { $states[ $id ] = InviteeState::FAILED->value; $logs[] = array( $user_id, (string) $row['email_hash'], LogState::FAILED, 'address', '' ); ++$out['failed']; continue; } $seed = (string) ( $row['token_seed'] ?? '' ); $token = Tokens::for_invitee( $id, $seed ); if ( '' === $token || ! hash_equals( (string) ( $row['token_hash'] ?? '' ), Tokens::hash( $token ) ) ) { $seed = Tokens::seed(); $token = Tokens::for_invitee( $id, $seed ); $tokens[ $id ] = array( Tokens::hash( $token ), $seed ); } $states[ $id ] = InviteeState::SENT->value; $lang = Language::normalize( (string) ( $user_id > 0 && '' !== (string) ( $row['person_lang'] ?? '' ) ? $row['person_lang'] : $row['lang'] ) ); $send[ $id ] = array( 'to' => $address, 'token' => $token, 'user_id' => $user_id, 'first' => $user_id > 0 ? (string) ( $row['display_name'] ?? '' ) : (string) $row['first_name'], 'lang' => '' !== $lang && in_array( $lang, Names::languages(), true ) ? $lang : $default, 'hash' => (string) $row['email_hash'], ); } self::write_states( $inv, $states, $tokens, array_keys( $send ), $expiry, $now ); cache_users( array_values( array_filter( array_map( static fn( array $m ): int => (int) $m['user_id'], $send ) ) ) ); $template = self::template_id( $inv ); $tracked = Tracking::on(); $marks = array(); $failed = array(); self::with_from_name( static function () use ( $send, $tracked, $template, $inv, &$marks, &$logs, &$out, &$failed ): void { foreach ( $send as $id => $mail ) { $log = $tracked ? MessageLog::add( $template, NotificationKey::INVITATION->value, $mail['user_id'], $mail['to'], LogObject::INVITATION, $inv->id, LogState::PREPARED, '', $mail['lang'] ) : 0; $first = self::first_name( $mail['user_id'], $mail['first'] ); $message = InvitationMail::message( $inv, $mail['user_id'], $first, $mail['lang'], $mail['token'], $log ); $ok = Services::notifier()->deliver( $mail['to'], $message['subject'], $message['html'], 0, $mail['lang'], array(), $message['headers'], NotificationKey::INVITATION->value, $mail['user_id'] ); if ( $log > 0 ) { $marks[ $log ] = $ok ? LogState::SENT : LogState::FAILED; } else { $logs[] = array( $mail['user_id'], $mail['hash'], $ok ? LogState::SENT : LogState::FAILED, $ok ? '' : 'mail', $mail['lang'] ); } if ( $ok ) { ++$out['sent']; self::announce( $inv, $id, $mail ); continue; } $failed[] = $id; } } ); if ( array() !== $failed ) { global $wpdb; $at_case = SqlPart::of( '' ); $version_case = SqlPart::of( '' ); foreach ( $failed as $id ) { $at_case = $at_case->add( null === $previous[ $id ][0] ? SqlPart::of( ' WHEN %d THEN NULL', $id ) : SqlPart::of( ' WHEN %d THEN %s', $id, $previous[ $id ][0] ) ); $version_case = $version_case->add( SqlPart::of( ' WHEN %d THEN %d', $id, $previous[ $id ][1] ) ); } Db::query( SqlPart::of( 'UPDATE %i SET state = %d, token_hash = NULL, token_seed = NULL, ', Db::table( 'invitees' ), InviteeState::FAILED->value )->add( 'sent_at = CASE id' )->add( $at_case )->add( ' ELSE sent_at END, sent_version = CASE id' )->add( $version_case )->add( ' ELSE sent_version END WHERE id IN (' . implode( ',', array_map( 'intval', $failed ) ) . ')' ) ); $out['failed'] += count( $failed ); } MessageLog::add_hashed( $template, NotificationKey::INVITATION->value, LogObject::INVITATION, $inv->id, $logs ); MessageLog::mark_many( $marks ); \RoosterLMS\Messages\Events::flush(); $out['more'] = count( $rows ) >= self::BATCH; if ( ! $out['more'] ) { self::finish( $invitation_id ); } return $out; } public static function remind_batch( Invitation $inv, int $after = 0 ): array { global $wpdb; $out = array( 'sent' => 0, 'skipped' => 0, 'more' => false, 'last' => $after, ); if ( ! $inv->asks() || $inv->is_cancelled() || '' === $inv->sent_at ) { return $out; } $sql = SqlPart::of( 'SELECT /* outside-tx */ i.id, i.user_id, i.email_hash, i.email, i.first_name, i.lang, i.token_hash, i.token_seed, u.user_email, u.display_name, p.lang AS person_lang, ' )->add( SendRules::consent_sql() )->add( ' AS consent, ' )->add( SendRules::unsubscribed_sql() )->add( ' AS unsub' )->add( SqlPart::of( ' FROM %i i LEFT JOIN %i u ON u.ID = i.user_id LEFT JOIN %i p ON p.user_id = i.user_id WHERE i.invitation_id = %d AND i.id > %d AND i.state = %d AND i.answer = %d AND i.source <> %d AND i.token_hash IS NOT NULL ORDER BY i.id LIMIT %d', Db::table( 'invitees' ), $wpdb->users, Db::table( 'people' ), $inv->id, max( 0, $after ), InviteeState::SENT->value, RsvpAnswer::NONE->value, InviteeSource::NEWSLETTER->value, self::BATCH ) ); $rows = Db::rows( $sql ); cache_users( array_values( array_filter( array_map( static fn( array $r ): int => (int) $r['user_id'], $rows ) ) ) ); $template = self::template_id( $inv ); $default = Names::default_language(); $logs = array(); self::with_from_name( static function () use ( $rows, $inv, $default, &$logs, &$out ): void { foreach ( $rows as $row ) { $id = (int) $row['id']; $out['last'] = $id; $user_id = (int) $row['user_id']; $address = $user_id > 0 ? (string) ( $row['user_email'] ?? '' ) : (string) $row['email']; $token = Tokens::for_invitee( $id, (string) ( $row['token_seed'] ?? '' ) ); if ( 1 === (int) $row['unsub'] || 0 === (int) $row['consent'] || '' === $token || ! hash_equals( (string) $row['token_hash'], Tokens::hash( $token ) ) || false === is_email( $address ) ) { ++$out['skipped']; continue; } $lang = Language::normalize( (string) ( $user_id > 0 && '' !== (string) ( $row['person_lang'] ?? '' ) ? $row['person_lang'] : $row['lang'] ) ); $lang = '' !== $lang && in_array( $lang, Names::languages(), true ) ? $lang : $default; $first = self::first_name( $user_id, $user_id > 0 ? (string) ( $row['display_name'] ?? '' ) : (string) $row['first_name'] ); $message = InvitationMail::message( $inv, $user_id, $first, $lang, $token, 0, true ); $ok = Services::notifier()->deliver( $address, $message['subject'], $message['html'], 0, $lang, array(), $message['headers'], NotificationKey::INVITATION->value, $user_id ); $logs[] = array( $user_id, (string) $row['email_hash'], $ok ? LogState::SENT : LogState::FAILED, $ok ? 'reminder' : 'mail', $lang ); if ( ! $ok ) { ++$out['skipped']; continue; } ++$out['sent']; do_action( 'rooster_lms_invitation_reminded', $inv->id, $id, $user_id, $inv->version ); } } ); MessageLog::add_hashed( $template, NotificationKey::INVITATION->value, LogObject::INVITATION, $inv->id, $logs ); $out['more'] = count( $rows ) >= self::BATCH; return $out; } private static function with_from_name( callable $mails ): void { $from = trim( (string) \RoosterLMS\Core\Settings::get( 'inv_from_name' ) ); if ( '' === $from ) { $mails(); return; } $name = static fn(): string => $from; add_filter( 'wp_mail_from_name', $name, 10 ); try { $mails(); } finally { remove_filter( 'wp_mail_from_name', $name, 10 ); } } private static function template_id( Invitation $inv ): int { if ( '' !== $inv->subject || '' !== $inv->body ) { return 0; } $t = MessagesBridge::template( $inv->template_id ); return null === $t ? 0 : $t->id; } private static function candidates( int $invitation_id, int $after ): array { global $wpdb; $sql = SqlPart::of( 'SELECT /* outside-tx */ i.id, i.user_id, i.email_hash, i.email, i.first_name, i.lang, i.source, i.state AS invitee_state, i.token_hash, i.token_seed, i.sent_at AS prev_sent_at, i.sent_version AS prev_version, u.user_email, u.display_name, p.lang AS person_lang, v.id AS inv_id, v.kind, v.course_id, v.unit_id, v.occurrence_id, v.template_id, v.announcement_id, v.version, v.attachment_ids, v.rsvp, v.rsvp_until, v.yes_action, v.places, v.state AS inv_state, v.scheduled_at, v.sent_at AS inv_sent_at, v.subject, v.body, v.lang AS inv_lang, v.created_by, v.created_at, v.target_cohort_id, ' )->add( SendRules::consent_sql() )->add( ' AS consent, ' )->add( SendRules::unsubscribed_sql() )->add( ' AS unsub, ' )->add( SendRules::recent_sql() )->add( ' AS recent' )->add( SqlPart::of( ' FROM %i i JOIN %i v ON v.id = i.invitation_id LEFT JOIN %i u ON u.ID = i.user_id LEFT JOIN %i p ON p.user_id = i.user_id WHERE i.invitation_id = %d AND i.id > %d AND ', Db::table( 'invitees' ), Db::table( 'invitations' ), $wpdb->users, Db::table( 'people' ), $invitation_id, max( 0, $after ) ) )->add( SendRules::due_sql( 'v.version' ) )->add( SqlPart::of( ' ORDER BY i.id LIMIT %d', self::BATCH ) ); return Db::rows( $sql ); } private static function invitation_columns( array $row ): array { return array( 'id' => $row['inv_id'], 'kind' => $row['kind'], 'course_id' => $row['course_id'], 'unit_id' => $row['unit_id'], 'occurrence_id' => $row['occurrence_id'], 'template_id' => $row['template_id'], 'announcement_id' => $row['announcement_id'], 'version' => $row['version'], 'attachment_ids' => $row['attachment_ids'], 'rsvp' => $row['rsvp'], 'rsvp_until' => $row['rsvp_until'], 'yes_action' => $row['yes_action'], 'places' => $row['places'], 'state' => $row['inv_state'], 'scheduled_at' => $row['scheduled_at'], 'sent_at' => $row['inv_sent_at'], 'subject' => $row['subject'], 'body' => $row['body'], 'lang' => $row['inv_lang'], 'created_by' => $row['created_by'], 'created_at' => $row['created_at'], 'target_cohort_id' => $row['target_cohort_id'], ); } private static function write_states( Invitation $inv, array $states, array $tokens, array $sent, string $expiry, string $now ): void { if ( array() === $states ) { return; } global $wpdb; $state_case = SqlPart::of( '' ); foreach ( $states as $id => $state ) { $state_case = $state_case->add( SqlPart::of( ' WHEN %d THEN %d', $id, $state ) ); } $sql = SqlPart::of( 'UPDATE ' )->add( SqlPart::of( '%i', Db::table( 'invitees' ) ) )->add( ' SET state = CASE id' )->add( $state_case )->add( ' ELSE state END' ); if ( array() !== $tokens ) { $hash_case = SqlPart::of( '' ); $seed_case = SqlPart::of( '' ); foreach ( $tokens as $id => $pair ) { $hash_case = $hash_case->add( SqlPart::of( ' WHEN %d THEN %s', $id, $pair[0] ) ); $seed_case = $seed_case->add( SqlPart::of( ' WHEN %d THEN %s', $id, $pair[1] ) ); } $sql = $sql->add( SqlPart::of( ', token_hash = CASE id' )->add( $hash_case )->add( ' ELSE token_hash END, token_seed = CASE id' )->add( $seed_case )->add( ' ELSE token_seed END' ) ); } if ( array() !== $sent ) { $ids = array_map( 'intval', $sent ); $sql = $sql->add( SqlPart::of( ', token_expires_at = CASE WHEN id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') THEN %s ELSE token_expires_at END', array_merge( $ids, array( $expiry ) ) )->add( SqlPart::of( ', sent_version = CASE WHEN id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') THEN %d ELSE sent_version END', array_merge( $ids, array( $inv->version ) ) ) )->add( SqlPart::of( ', sent_at = CASE WHEN id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') THEN %s ELSE sent_at END', array_merge( $ids, array( $now ) ) ) ) ); } $ids = array_map( 'intval', array_keys( $states ) ); $sql = $sql->add( SqlPart::of( ' WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ) ); Db::query( $sql ); } private static function finish( int $invitation_id ): void { $done = Db::query( 'UPDATE %i SET state = %d, updated_at = %s WHERE id = %d AND state = %d', Db::table( 'invitations' ), InvitationState::SENT->value, Clock::now(), $invitation_id, InvitationState::SENDING->value ); InvitationRepository::forget( $invitation_id ); if ( $done > 0 ) { do_action( 'rooster_lms_invitation_finished', $invitation_id ); } } private static function announce( Invitation $inv, int $id, array $mail ): void { $user_id = (int) $mail['user_id']; do_action( 'rooster_lms_invitation_sent', $inv->id, $id, $user_id, $inv->version ); Events::fire( 'invitation_sent', array( 'user_id' => $user_id, 'email_hash' => (string) $mail['hash'], 'lang' => (string) $mail['lang'], 'course_id' => $inv->course_id, 'unit_id' => $inv->unit_id, 'occurrence_id' => $inv->occurrence_id, 'invitation_id' => $inv->id, 'invitee_id' => $id, 'version' => $inv->version, ) ); } private static function first_name( int $user_id, string $first ): string { if ( $user_id <= 0 ) { return $first; } $user = get_userdata( $user_id ); return $user instanceof \WP_User && '' !== (string) $user->first_name ? (string) $user->first_name : $first; } }