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( sprintf( __( 'Hello %s,', 'rooster-lms' ), $first ) ) . '

'; switch ( $key ) { case NotificationKey::WELCOME: $list = $syllabus->for_member( $org_id, (int) $user->ID, $lang ); /* translators: %s: site name. */ $subject = sprintf( __( 'Welcome to %s: your courses', 'rooster-lms' ), $site ); $intro = '' !== $company /* translators: 1: organisation name, 2: site name. */ ? sprintf( __( '%1$s has registered you on %2$s. Below are the courses assigned to you, in the order we recommend you follow them.', 'rooster-lms' ), $company, $site ) /* translators: %s: site name. */ : sprintf( __( 'You have been registered on %s. Below are the courses assigned to you, in the order we recommend you follow them.', 'rooster-lms' ), $site ); break; case NotificationKey::PROGRAMME_CHANGED: $list = $syllabus->for_member( $org_id, (int) $user->ID, $lang, $courses ); $subject = __( 'New in your training programme', 'rooster-lms' ); $intro = __( 'New courses have been added to your training programme:', 'rooster-lms' ); break; case NotificationKey::JOB_ROLE_CHANGED: $names = Services::job_role_repo()->names( array( (int) ( $payload['from'] ?? 0 ), (int) ( $payload['to'] ?? 0 ) ), $lang ); $to_name = $names[ (int) ( $payload['to'] ?? 0 ) ] ?? ''; $new = $this->new_for_role( $org_id, (int) $user->ID, (int) ( $payload['from'] ?? 0 ) ); $list = array() === $new ? array() : $syllabus->for_member( $org_id, (int) $user->ID, $lang, $new ); $subject = __( 'Your job role has changed', 'rooster-lms' ); $intro = '' !== $to_name /* translators: %s: the new job role. */ ? sprintf( __( 'Your job role is now %s.', 'rooster-lms' ), $to_name ) . ( array() === $list ? '' : ' ' . __( 'These courses are new in your training programme:', 'rooster-lms' ) ) : __( 'Your job role has changed.', 'rooster-lms' ); $preview = true; break; case NotificationKey::QUIZ_REGRADED: $list = $this->regraded_list( (int) $user->ID, (array) ( $payload['attempt_ids'] ?? array() ), $lang ); $subject = __( 'Your quiz result has been updated', 'rooster-lms' ); $intro = __( 'The answer key of a quiz you took was corrected, and your result was recalculated:', 'rooster-lms' ); break; case NotificationKey::RESULTS_RELEASED: $list = $this->regraded_list( (int) $user->ID, (array) ( $payload['attempt_ids'] ?? array() ), $lang ); $subject = __( 'Your exam results have been published', 'rooster-lms' ); $intro = __( 'The results of an exam you took have been published:', 'rooster-lms' ); break; case NotificationKey::ANNOUNCEMENT: $list = self::announcement_list( (array) ( $payload['announcement_ids'] ?? array() ) ); $subject = 1 === count( $list ) ? (string) $list[0]['title'] : __( 'New announcements', 'rooster-lms' ); $intro = _n( 'There is an announcement for you:', 'There are announcements for you:', max( 1, count( $list ) ), 'rooster-lms' ); break; case NotificationKey::PLAN_EXPIRING: case NotificationKey::PLAN_STATUS: $message = \RoosterLMS\S1b\PlanNotices::message( $key, (int) $user->ID, $org_id, $lang, $payload ); $list = $message['list']; $subject = $message['subject']; $intro = $message['intro']; break; case NotificationKey::PLACEMENT_CHANGED: case NotificationKey::PROMOTION_COMPLETED: $list = $syllabus->for_courses( (int) $user->ID, $courses, $lang ); $subject = NotificationKey::PLACEMENT_CHANGED === $key ? __( 'Your placement has changed', 'rooster-lms' ) : __( 'You have moved up', 'rooster-lms' ); $intro = NotificationKey::PLACEMENT_CHANGED === $key ? __( 'You have a new placement. These are your courses now:', 'rooster-lms' ) : __( 'Your new school year has started. These are your courses now:', 'rooster-lms' ); break; case NotificationKey::ENROLLED_BULK: $list = $syllabus->for_courses( (int) $user->ID, $courses, $lang ); /* translators: %d: number of courses. */ $subject = sprintf( _n( 'You have been enrolled in %d course', 'You have been enrolled in %d courses', max( 1, count( $list ) ), 'rooster-lms' ), max( 1, count( $list ) ) ); $intro = __( 'You now have access to:', 'rooster-lms' ); break; default: $list = $syllabus->for_courses( (int) $user->ID, $courses, $lang, false ); /* translators: %d: number of courses. */ $subject = sprintf( _n( 'You have been removed from %d course', 'You have been removed from %d courses', max( 1, count( $list ) ), 'rooster-lms' ), max( 1, count( $list ) ) ); $intro = __( 'You no longer have access to:', 'rooster-lms' ); break; } if ( array() === $list && ! $preview ) { return null; } if ( null !== $template ) { return \RoosterLMS\Messages\NotifierBridge::generic( $template, $user, $org_id, $lang, $payload, $subject, self::render_list( $list, NotificationKey::UNENROLLED_BULK !== $key ) ); } $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 = '
    '; foreach ( $courses as $course ) { $title = esc_html( $course['title'] ); $h .= '
  1. ' . ( $links && '' !== $course['url'] ? '' . $title . '' : $title ); if ( array() !== $course['items'] ) { $h .= '
      '; foreach ( $course['items'] as $item ) { $h .= '
    1. ' . esc_html( $item['title'] ) . '
    2. '; } $h .= '
    '; } $h .= '
  2. '; } return $h . '
'; } private function mail( string $to, string $subject, string $html, int $org_id, string $lang = '', array $attachments = array(), array $headers = array(), int $user_id = 0 ): bool { $extra = array(); foreach ( $headers as $header ) { $header = trim( str_replace( array( "\r", "\n" ), '', (string) $header ) ); if ( 1 === preg_match( '/^[A-Za-z][A-Za-z0-9-]*:\s*\S/', $header ) ) { $extra[] = $header; } } $org = $org_id > 0 ? Services::org_repo()->get( $org_id ) : null; $sender = null === $org ? '' : \RoosterLMS\Orgs\OrgBranding::of( $org, $lang, $user_id )->sender_name; $name = static fn( $current ) => '' !== $sender ? $sender : $current; $attach = static function ( $phpmailer ) use ( $attachments ): void { if ( is_object( $phpmailer ) && method_exists( $phpmailer, 'addStringAttachment' ) ) { foreach ( $attachments as $file => $content ) { $phpmailer->addStringAttachment( (string) $content, sanitize_file_name( (string) $file ), 'base64', 'text/calendar; charset=utf-8; method=PUBLISH' ); } } }; add_filter( 'wp_mail_from_name', $name, 20 ); if ( array() !== $attachments ) { add_action( 'phpmailer_init', $attach, 20 ); } try { return (bool) MemberMailWrapper::own( static fn() => wp_mail( $to, $subject, $html, array_merge( array( 'Content-Type: text/html; charset=UTF-8' ), $extra ) ) ); } finally { remove_filter( 'wp_mail_from_name', $name, 20 ); remove_action( 'phpmailer_init', $attach, 20 ); } } public function deliver( string $to, string $subject, string $html, int $org_id = 0, string $lang = '', array $attachments = array(), array $headers = array(), string $key = '', int $user_id = 0 ): bool { if ( ! is_email( $to ) || '' === trim( $subject ) ) { return false; } $sent = $this->mail( $to, $subject, $html, $org_id, $lang, $attachments, $headers, $user_id ); if ( $sent && '' !== $key ) { do_action( 'rooster_lms_notification_sent', 0, $key, $user_id ); } return $sent; } private function hold_quiet(): void { $at = \RoosterLMS\Messages\QuietHours::ends_at(); $live = array(); foreach ( NotificationKey::cases() as $key ) { if ( $key->is_live() ) { $live[] = $key->value; } } Db::query( 'UPDATE %i SET send_after = %s WHERE status = %d AND send_after <= %s AND notif_key NOT IN (' . implode( ',', array_fill( 0, count( $live ), '%s' ) ) . ')', array_merge( array( Db::table( 'notifications' ), gmdate( 'Y-m-d H:i:s', $at ), NotificationStatus::PENDING->value, Clock::now() ), $live ) ); NotifyJobs::schedule( $at ); } private function finish( int $id, NotificationStatus $status, string $lang = '' ): void { $now = Clock::now(); if ( NotificationStatus::SENT === $status ) { Db::query( 'UPDATE %i SET status = %d, sent_at = %s, lang = %s, open_slot = NULL, tries = tries + 1 WHERE id = %d', Db::table( 'notifications' ), $status->value, $now, $lang, $id ); return; } Db::query( 'UPDATE %i SET status = %d, open_slot = NULL, tries = tries + 1 WHERE id = %d', Db::table( 'notifications' ), $status->value, $id ); } private function retry( int $id, NotificationKey $key, int $recipient, int $org_id, int $minutes ): void { Transaction::run( static function () use ( $id, $key, $recipient, $org_id, $minutes ): void { $open = Db::value( 'SELECT id FROM %i WHERE recipient_id = %d AND notif_key = %s AND org_id = %d AND open_slot = 1 FOR UPDATE', Db::table( 'notifications' ), $recipient, $key->value, $org_id ); $after = gmdate( 'Y-m-d H:i:s', Clock::timestamp() + $minutes * MINUTE_IN_SECONDS ); if ( null === $open ) { Db::query( 'UPDATE %i SET tries = tries + 1, send_after = %s, open_slot = 1 WHERE id = %d', Db::table( 'notifications' ), $after, $id ); } else { Db::query( 'UPDATE %i SET tries = tries + 1, send_after = %s WHERE id = %d', Db::table( 'notifications' ), $after, $id ); } } ); NotifyJobs::schedule( Clock::timestamp() + $minutes * MINUTE_IN_SECONDS ); } private static function clean( array $payload ): array { $out = array(); if ( isset( $payload['course_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['course_ids'] ); sort( $ids ); $out['course_ids'] = $ids; } foreach ( array( 'from', 'to', 'run_id', 'unit_id', 'assignment_id', 'days', 'plan_status' ) as $key ) { if ( isset( $payload[ $key ] ) ) { $out[ $key ] = max( 0, (int) $payload[ $key ] ); } } if ( isset( $payload['attempt_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['attempt_ids'] ); sort( $ids ); $out['attempt_ids'] = $ids; } if ( isset( $payload['submission_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['submission_ids'] ); sort( $ids ); $out['submission_ids'] = array_slice( $ids, 0, \RoosterLMS\Messages\GradingNotice::MAX ); } if ( isset( $payload['report_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['report_ids'] ); sort( $ids ); $out['report_ids'] = array_slice( $ids, 0, \RoosterLMS\Messages\QuizReportNotice::MAX ); } if ( isset( $payload['announcement_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['announcement_ids'] ); sort( $ids ); $out['announcement_ids'] = $ids; } if ( isset( $payload['learner_ids'] ) ) { $ids = CourseRepository::ids( (array) $payload['learner_ids'] ); sort( $ids ); $out['learner_ids'] = array_slice( $ids, 0, 500 ); } if ( isset( $payload['live'] ) && is_array( $payload['live'] ) ) { $out['live'] = \RoosterLMS\Live\Notices\LiveNotices::merge_items( '', array(), $payload['live'] ); } if ( isset( $payload['reason'] ) && in_array( $payload['reason'], array( 'regraded', 'reevaluated' ), true ) ) { $out['reason'] = (string) $payload['reason']; } return $out; } private static function merge( NotificationKey $key, array $old, array $change ): array { $old = self::clean( $old ); if ( NotificationKey::WELCOME === $key ) { return $old; } if ( NotificationKey::JOB_ROLE_CHANGED === $key ) { $old['from'] = $old['from'] ?? (int) ( $change['from'] ?? 0 ); $old['to'] = (int) ( $change['to'] ?? ( $old['to'] ?? 0 ) ); return $old; } if ( NotificationKey::ANNOUNCEMENT === $key || $key->is_offer() ) { $ids = array_values( array_unique( array_merge( (array) ( $old['announcement_ids'] ?? array() ), (array) ( $change['announcement_ids'] ?? array() ) ) ) ); sort( $ids ); $old['announcement_ids'] = $ids; return $old; } if ( $key->is_live() ) { $old['live'] = \RoosterLMS\Live\Notices\LiveNotices::merge_items( $key->value, (array) ( $old['live'] ?? array() ), (array) ( $change['live'] ?? array() ) ); return $old; } if ( $key->is_grading() ) { return \RoosterLMS\Messages\GradingNotice::merge( $old, self::clean( $change ) ); } if ( $key->is_report() ) { return \RoosterLMS\Messages\QuizReportNotice::merge( $old, self::clean( $change ) ); } if ( NotificationKey::PLAN_EXPIRING === $key || NotificationKey::PLAN_STATUS === $key ) { return array_merge( $old, self::clean( $change ) ); } if ( NotificationKey::QUIZ_REGRADED === $key || NotificationKey::RESULTS_RELEASED === $key ) { $ids = array_values( array_unique( array_merge( (array) ( $old['attempt_ids'] ?? array() ), (array) ( $change['attempt_ids'] ?? array() ) ) ) ); sort( $ids ); $old['attempt_ids'] = $ids; if ( isset( $change['unit_id'] ) ) { $old['unit_id'] = (int) $change['unit_id']; } if ( isset( $change['reason'] ) && in_array( $change['reason'], array( 'regraded', 'reevaluated' ), true ) ) { $old['reason'] = (string) $change['reason']; } return $old; } $ids = array_values( array_unique( array_merge( (array) ( $old['course_ids'] ?? array() ), (array) ( $change['course_ids'] ?? array() ) ) ) ); sort( $ids ); $old['course_ids'] = $ids; if ( $key->is_staff() ) { $learners = array_values( array_unique( array_merge( (array) ( $old['learner_ids'] ?? array() ), array_map( 'intval', (array) ( $change['learner_ids'] ?? array() ) ) ) ) ); sort( $learners ); $old['learner_ids'] = array_slice( $learners, 0, 500 ); } if ( isset( $change['run_id'] ) ) { $old['run_id'] = (int) $change['run_id']; } return $old; } }