is_import() || $recipient_id <= 0 || ! self::enabled( $key ) ) { return; } if ( NotificationKey::JOB_ROLE_CHANGED === $key && isset( self::$new_users[ $recipient_id ] ) ) { return; } $payload = self::clean( $payload ); $due = null; Transaction::run( static function () use ( $key, $recipient_id, $org_id, $payload, &$due ): void { Services::people()->lock( $recipient_id ); if ( $org_id > 0 ) { $member = Db::row( 'SELECT left_at FROM %i WHERE org_id = %d AND user_id = %d FOR UPDATE', Db::table( 'org_members' ), $org_id, $recipient_id ); if ( null === $member || null !== $member['left_at'] ) { return; } } if ( NotificationKey::WELCOME === $key && null !== Db::value( 'SELECT id FROM %i WHERE recipient_id = %d AND notif_key = %s AND status = %d LIMIT 1 FOR UPDATE', Db::table( 'notifications' ), $recipient_id, $key->value, NotificationStatus::SENT->value ) ) { return; } $now = Transaction::now(); $open = Db::row( 'SELECT id, payload, send_after FROM %i WHERE recipient_id = %d AND notif_key = %s AND org_id = %d AND open_slot = 1 FOR UPDATE', Db::table( 'notifications' ), $recipient_id, $key->value, $org_id ); if ( null === $open ) { $send_after = $key->is_digest() ? gmdate( 'Y-m-d H:i:s', (int) strtotime( $now . ' UTC' ) + self::delay( $key ) ) : $now; Db::query( "INSERT INTO %i (recipient_id, notif_key, org_id, open_slot, lang, payload, first_change_at, send_after, status, tries) VALUES (%d, %s, %d, 1, '', %s, %s, %s, %d, 0)", Db::table( 'notifications' ), $recipient_id, $key->value, $org_id, (string) wp_json_encode( (object) $payload ), $now, $send_after, NotificationStatus::PENDING->value ); $due = $send_after; return; } $old = json_decode( (string) $open['payload'], true ); $merged = self::merge( $key, is_array( $old ) ? $old : array(), $payload ); if ( wp_json_encode( (object) $merged ) !== wp_json_encode( (object) ( is_array( $old ) ? $old : array() ) ) ) { Db::query( 'UPDATE %i SET payload = %s WHERE id = %d', Db::table( 'notifications' ), (string) wp_json_encode( (object) $merged ), (int) $open['id'] ); } $due = (string) $open['send_after']; } ); if ( null !== $due ) { $at = (int) strtotime( $due . ' UTC' ); Transaction::after_commit( static function () use ( $at ): void { NotifyJobs::schedule( $at ); } ); } } public function send_due( int $limit = 50, int $budget = 20 ): array { $out = array( 'sent' => 0, 'skipped' => 0, 'failed' => 0, ); $deadline = microtime( true ) + max( 1, $budget ); if ( \RoosterLMS\Messages\QuietHours::active() ) { $this->hold_quiet(); } $ids = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE status = %d AND send_after <= %s ORDER BY send_after, id LIMIT %d /* outside-tx */', Db::table( 'notifications' ), NotificationStatus::PENDING->value, Clock::now(), max( 1, $limit ) ) ); foreach ( $ids as $id ) { if ( microtime( true ) >= $deadline ) { break; } $row = $this->claim( $id ); if ( null === $row ) { continue; } $key = NotificationKey::from( (string) $row['notif_key'] ); $payload = $this->deliverable( $key, $row ); if ( null === $payload ) { $this->finish( $id, NotificationStatus::SKIPPED ); \RoosterLMS\Messages\MessageLog::notification( $id, $key, (int) $row['recipient_id'], $row, NotificationStatus::SKIPPED, '' ); ++$out['skipped']; continue; } $recipient = (int) $row['recipient_id']; $org_id = (int) $row['org_id']; $lang = $this->language_of( $recipient, $org_id ); $message = $this->compose( $key, $recipient, $org_id, $lang, $payload, false ); $user = get_userdata( $recipient ); if ( null === $message || ! $user instanceof \WP_User ) { $this->finish( $id, NotificationStatus::SKIPPED ); \RoosterLMS\Messages\MessageLog::notification( $id, $key, $recipient, $row, NotificationStatus::SKIPPED, $lang ); ++$out['skipped']; continue; } $tracked = \RoosterLMS\Messages\MessageLog::prepare_notification( $id, $key, $recipient, (string) $user->user_email, $lang ); $html = $tracked > 0 ? \RoosterLMS\Messages\Tracking::instrument( $message['html'], $tracked ) : $message['html']; if ( $this->mail( (string) $user->user_email, $message['subject'], $html, $org_id, $lang, (array) ( $message['attachments'] ?? array() ), (array) ( $message['headers'] ?? array() ), $recipient ) ) { $this->finish( $id, NotificationStatus::SENT, $lang ); \RoosterLMS\Messages\MessageLog::notification( $id, $key, $recipient, $row, NotificationStatus::SENT, $lang, (string) $user->user_email, $tracked ); ++$out['sent']; $key_value = $key->value; do_action( 'rooster_lms_notification_sent', $id, $key_value, $recipient ); continue; } $tries = (int) $row['tries'] + 1; if ( $tries >= self::MAX_TRIES ) { $this->finish( $id, NotificationStatus::FAILED ); \RoosterLMS\Messages\MessageLog::notification( $id, $key, $recipient, $row, NotificationStatus::FAILED, $lang, (string) $user->user_email, $tracked ); ++$out['failed']; continue; } if ( $tracked > 0 ) { \RoosterLMS\Messages\MessageLog::mark( $tracked, \RoosterLMS\Messages\LogState::FAILED, 'retry' ); } $this->retry( $id, $key, $recipient, $org_id, self::BACKOFF[ $tries - 1 ] ?? 120 ); ++$out['failed']; } return $out; } public function preview( NotificationKey $key, int $recipient_id, int $org_id, string $lang, array $payload = array() ): array { $lang = '' !== Language::normalize( $lang ) ? Language::normalize( $lang ) : $this->language_of( $recipient_id, $org_id ); $payload = self::clean( $payload ); if ( ! isset( $payload['course_ids'] ) && NotificationKey::JOB_ROLE_CHANGED !== $key && ! $key->is_live() && ! $key->is_offer() ) { $payload['course_ids'] = array_map( static fn( array $c ): int => $c['course_id'], Services::syllabus()->for_member( $org_id, $recipient_id, $lang ) ); } return $this->compose( $key, $recipient_id, $org_id, $lang, $payload, true ) ?? array( 'subject' => '', 'html' => '', ); } public function record_history( NotificationKey $key, int $recipient_id, int $org_id, string $at, NotificationStatus $status, Actor $actor ): void { if ( ! $actor->is_trusted() ) { DomainError::raise( ErrorCode::FORBIDDEN ); } if ( $recipient_id <= 0 || ! in_array( $status, array( NotificationStatus::SENT, NotificationStatus::SKIPPED ), true ) ) { DomainError::raise( ErrorCode::INVALID ); } Transaction::run( static function () use ( $key, $recipient_id, $org_id, $at, $status ): void { $found = Db::value( 'SELECT id FROM %i WHERE recipient_id = %d AND notif_key = %s AND org_id = %d AND open_slot IS NULL AND status = %d AND first_change_at = %s LIMIT 1 FOR UPDATE', Db::table( 'notifications' ), $recipient_id, $key->value, $org_id, $status->value, $at ); if ( null !== $found ) { return; } if ( NotificationStatus::SENT === $status ) { Db::query( "INSERT INTO %i (recipient_id, notif_key, org_id, open_slot, lang, payload, first_change_at, send_after, sent_at, status, tries) VALUES (%d, %s, %d, NULL, '', '{}', %s, %s, %s, %d, 0)", Db::table( 'notifications' ), $recipient_id, $key->value, $org_id, $at, $at, $at, $status->value ); } else { Db::query( "INSERT INTO %i (recipient_id, notif_key, org_id, open_slot, lang, payload, first_change_at, send_after, status, tries) VALUES (%d, %s, %d, NULL, '', '{}', %s, %s, %d, 0)", Db::table( 'notifications' ), $recipient_id, $key->value, $org_id, $at, $at, $status->value ); } } ); } public static function mark_new_user( int $user_id ): void { self::$new_users[ $user_id ] = true; } public function delete_user( int $user_id ): int { return Db::query( 'DELETE FROM %i WHERE recipient_id = %d', Db::table( 'notifications' ), $user_id ); } public function next_due(): ?string { return Db::value( 'SELECT MIN(send_after) FROM %i WHERE status = %d /* outside-tx */', Db::table( 'notifications' ), NotificationStatus::PENDING->value ); } public static function enabled( NotificationKey $key ): bool { if ( '' !== $key->setting() && true !== Settings::get( $key->setting() ) ) { return false; } return \RoosterLMS\Messages\TemplateRepository::key_enabled( $key ); } public static function delay( NotificationKey $key ): int { $seconds = (int) Settings::get( 'notify_delay_minutes' ) * MINUTE_IN_SECONDS; return max( 0, (int) apply_filters( 'rooster_lms_notification_delay', $seconds, $key->value ) ); } private function claim( int $id ): ?array { return Transaction::run( static function () use ( $id ): ?array { $now = Transaction::now(); $row = Db::row( 'SELECT * FROM %i WHERE id = %d AND status = %d FOR UPDATE', Db::table( 'notifications' ), $id, NotificationStatus::PENDING->value ); if ( null === $row || (string) $row['send_after'] > $now ) { return null; } $lease = gmdate( 'Y-m-d H:i:s', (int) strtotime( $now . ' UTC' ) + self::LEASE ); Db::query( 'UPDATE %i SET open_slot = NULL, send_after = %s WHERE id = %d', Db::table( 'notifications' ), $lease, $id ); return $row; } ); } private function deliverable( NotificationKey $key, array $row ): ?array { $recipient = (int) $row['recipient_id']; $org_id = (int) $row['org_id']; $payload = json_decode( (string) $row['payload'], true ); $payload = self::clean( is_array( $payload ) ? $payload : array() ); if ( ! self::enabled( $key ) || ! get_userdata( $recipient ) instanceof \WP_User ) { return null; } if ( $org_id > 0 ) { $member = Services::member_repo()->get( $org_id, $recipient ); if ( null === $member || ! $member->is_current() || StaffStatus::ACTIVE !== StaffStatus::effective( $member, Clock::org_today( $org_id ) ) ) { return null; } if ( NotificationKey::JOB_ROLE_CHANGED === $key && (int) ( $payload['from'] ?? 0 ) === $member->job_role_id ) { return null; } } if ( $key->is_grading() ) { return \RoosterLMS\Messages\GradingNotice::deliverable( $recipient, $payload ); } if ( $key->is_report() ) { return \RoosterLMS\Messages\QuizReportNotice::deliverable( $key, $recipient, $payload ); } if ( $key->is_staff() ) { $taught = array_values( array_intersect( (array) ( $payload['course_ids'] ?? array() ), Services::course_staff()->course_ids_of( $recipient ) ) ); $payload['course_ids'] = $taught; return array() === $taught || array() === (array) ( $payload['learner_ids'] ?? array() ) ? null : $payload; } if ( NotificationKey::PROGRAMME_CHANGED === $key ) { $welcome = Db::value( 'SELECT id FROM %i WHERE recipient_id = %d AND notif_key = %s AND status = %d AND sent_at >= %s LIMIT 1 /* outside-tx */', Db::table( 'notifications' ), $recipient, NotificationKey::WELCOME->value, NotificationStatus::SENT->value, (string) $row['first_change_at'] ); if ( null !== $welcome ) { return null; } } if ( $key->is_live() ) { return \RoosterLMS\Live\Notices\LiveNotices::deliverable( $key, $recipient, $payload ); } if ( ( NotificationKey::PLAN_EXPIRING === $key || NotificationKey::PLAN_STATUS === $key ) && ! \RoosterLMS\S1b\PlanNotices::deliverable( $key, $payload ) ) { return null; } if ( $key->is_offer() ) { return \RoosterLMS\Earlier\EarlierService::deliverable( $recipient, $payload ); } if ( NotificationKey::ANNOUNCEMENT === $key ) { $live = array_keys( \RoosterLMS\Account\AnnouncementRepository::published( (array) ( $payload['announcement_ids'] ?? array() ) ) ); if ( array() === $live ) { return null; } $payload['announcement_ids'] = $live; } if ( isset( $payload['course_ids'] ) ) { $granted = $this->granted( $recipient, $org_id, $payload['course_ids'] ); $keep = NotificationKey::UNENROLLED_BULK === $key ? array_values( array_diff( $payload['course_ids'], $granted ) ) : $granted; if ( array() === $keep ) { return null; } $payload['course_ids'] = $keep; } return $payload; } private function granted( int $user_id, int $org_id, array $course_ids ): array { $course_ids = CourseRepository::ids( $course_ids ); if ( array() === $course_ids ) { return array(); } global $wpdb; $sql = SqlPart::of( 'SELECT DISTINCT course_id FROM %i WHERE user_id = %d AND course_id IN (' . implode( ',', array_fill( 0, count( $course_ids ), '%d' ) ) . ') AND revoked_at IS NULL /* outside-tx */', array_merge( array( Db::table( 'grants' ), $user_id ), $course_ids ) ); if ( $org_id > 0 ) { $sql = $sql->add( SqlPart::of( ' AND org_id = %d', $org_id ) ); } $have = array_map( 'intval', Db::col( $sql ) ); return array_values( array_intersect( $course_ids, $have ) ); } public function language_of( int $user_id, int $org_id ): string { $lang = Language::normalize( (string) Db::value( 'SELECT lang FROM %i WHERE user_id = %d /* outside-tx */', Db::table( 'people' ), $user_id ) ); if ( '' === $lang && $org_id > 0 ) { $lang = Language::normalize( (string) Services::org_repo()->get( $org_id )?->lang ); } return '' !== $lang ? $lang : Providers::current()->default_language(); } private function compose( NotificationKey $key, int $user_id, int $org_id, string $lang, array $payload, bool $preview ): ?array { $user = get_userdata( $user_id ); if ( ! $user instanceof \WP_User ) { return null; } if ( $key->is_live() ) { return \RoosterLMS\Live\Notices\NoticeMail::message( $key->value, $user, $lang, (array) ( $payload['live'] ?? array() ) ); } if ( $key->is_offer() ) { return \RoosterLMS\Earlier\EarlierMail::message( $user, $lang, $payload, $preview ); } if ( NotificationKey::INVITATION === $key ) { return $preview ? \RoosterLMS\Invitations\InvitationMail::preview( $user, $lang ) : null; } $template = \RoosterLMS\Messages\NotifierBridge::template_for( $key ); if ( $key->is_grading() ) { return null === $template ? null : \RoosterLMS\Messages\GradingNotice::message( $template, $user, $lang, $payload ); } if ( $key->is_report() ) { return \RoosterLMS\Messages\QuizReportNotice::message( $key, $user, $lang, $payload ); } if ( $key->is_staff() ) { return null === $template ? null : \RoosterLMS\Messages\NotifierBridge::staff_digest( $template, $key, $user, $lang, $payload ); } return Switcher::with( $lang, function () use ( $key, $user, $org_id, $lang, $payload, $preview, $template ): ?array { $syllabus = Services::syllabus(); $courses = array_map( 'intval', (array) ( $payload['course_ids'] ?? array() ) ); $site = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); $org = $org_id > 0 ? Services::org_repo()->get( $org_id ) : null; $company = null === $org ? '' : \RoosterLMS\Orgs\OrgBranding::of( $org, $lang, (int) $user->ID )->name; $first = '' !== (string) $user->first_name ? (string) $user->first_name : (string) $user->display_name; $font = 'font-family:Arial,Helvetica,sans-serif;'; /* translators: %s: the recipient's first name. */ $h = '
' . esc_html( $intro ) . '
'; $h .= self::render_list( $list, NotificationKey::UNENROLLED_BULK !== $key ); $h .= '' . esc_html( $site ) . '
'; return array( 'subject' => $subject, 'html' => Services::branded_renderer()->render( $org_id, $lang, $subject, $h, (int) $user->ID ), ); } ); } private static function announcement_list( array $ids ): array { $out = array(); foreach ( \RoosterLMS\Account\AnnouncementRepository::published( array_map( 'intval', $ids ) ) as $a ) { $text = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $a->body ) ) ); $out[] = array( 'course_id' => 0, 'title' => $a->title, 'url' => \RoosterLMS\Account\AccountUrls::has_page() ? \RoosterLMS\Account\AccountUrls::url( 'announcements', 'learner' ) : '', 'items' => '' === $text ? array() : array( array( 'title' => mb_strlen( $text ) > 300 ? mb_substr( $text, 0, 299 ) . '…' : $text, 'url' => '', ), ), ); } return $out; } private function regraded_list( int $user_id, array $attempt_ids, string $lang ): array { $out = array(); foreach ( CourseRepository::ids( $attempt_ids ) as $attempt_id ) { $attempt = Services::attempt_repo()->get( $attempt_id ); if ( null === $attempt || $attempt->user_id !== $user_id || $attempt->withheld() ) { continue; } $titles = Services::posts()->titles( \RoosterLMS\Content\ObjectType::UNIT, array( $attempt->unit_id ), $lang ); $out[] = array( 'course_id' => $attempt->course_id, 'title' => (string) ( $titles[ $attempt->unit_id ]['title'] ?? '' ), 'url' => \RoosterLMS\Routing\Urls::item_url( $attempt->course_id, $attempt->item_id, $lang ), 'items' => array( array( /* translators: %s: a score in percent, e.g. 80%. */ 'title' => sprintf( __( 'Your score: %s', 'rooster-lms' ), Format::percent( (int) round( (float) $attempt->score_pct ) ) ), 'url' => '', ), ), ); } return $out; } private function new_for_role( int $org_id, int $user_id, int $from ): array { $now = array_keys( Services::programme()->desired( $org_id, $user_id ) ); $before = $from > 0 ? Services::programme()->job_role_courses( $org_id, $from ) : array(); return array_values( array_diff( $now, $before ) ); } private static function render_list( array $courses, bool $links ): string { if ( array() === $courses ) { return ''; } $font = 'font-family:Arial,Helvetica,sans-serif;'; $h = '