query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Background job on an indexed flag. $wpdb->prepare( 'UPDATE %i SET email_pending = 0 WHERE email_pending = 1 AND created_at < %s', $table, gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ); do { $ids = array_map( 'intval', (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Background job on an indexed flag. $wpdb->prepare( 'SELECT id FROM %i WHERE email_pending = 1 ORDER BY id ASC LIMIT 50', $table ) ) ); foreach ( $ids as $id ) { // Claimed first and only by one run: an email is never sent twice, also when two // runs overlap or a run is cut short. $claimed = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->prepare( 'UPDATE %i SET email_pending = 0 WHERE id = %d AND email_pending = 1', $table, $id ) ); if ( 1 !== (int) $claimed ) { continue; } try { Mailer::send( $id ); } catch ( \Throwable $error ) { // One email that cannot be built or sent must not hold up the others. unset( $error ); } ++$handled; if ( microtime( true ) >= $until ) { break; } } } while ( $ids && microtime( true ) < $until ); // A clean finish: drop the safety job, then plan a new one only when emails are left. wp_unschedule_hook( self::QUEUE_HOOK ); $left = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Background job on an indexed flag. $wpdb->prepare( 'SELECT id FROM %i WHERE email_pending = 1 LIMIT 1', $table ) ); if ( $left ) { self::plan_emails(); } return $handled; } /** * Notifies members mentioned in a new post. * * @param array $post Post row. * @param int[] $mentions Mentioned user IDs. * @return void */ public static function on_post_created( $post, $mentions ) { foreach ( (array) $mentions as $user_id ) { self::add( (int) $user_id, $post['user_id'], 'mention_post', 'post', $post['id'], self::post_data( $post ), 'posts/' . $post['id'] ); } } /** * Notifies the post author, the parent comment author and mentioned members. * * @param array $comment Comment row. * @param array $post Post row. * @param int[] $mentions Mentioned user IDs. * @return void */ public static function on_comment_created( $comment, $post, $mentions ) { $data = self::post_data( $post, $comment['message'] ); $route = 'posts/' . $post['id']; $notified = array( (int) $comment['user_id'] ); if ( $comment['parent_id'] ) { $parent = Comments::find( $comment['parent_id'] ); if ( $parent && ! in_array( $parent['user_id'], $notified, true ) ) { self::add( $parent['user_id'], $comment['user_id'], 'reply_comment', 'post', $post['id'], $data, $route ); $notified[] = $parent['user_id']; } } if ( ! in_array( (int) $post['user_id'], $notified, true ) ) { self::add( $post['user_id'], $comment['user_id'], 'comment_post', 'post', $post['id'], $data, $route ); $notified[] = (int) $post['user_id']; } foreach ( (array) $mentions as $user_id ) { if ( ! in_array( (int) $user_id, $notified, true ) ) { self::add( (int) $user_id, $comment['user_id'], 'mention_comment', 'post', $post['id'], $data, $route ); } } } /** * Notifies authors when their post or comment is liked. * * @param string $object_type "post" or "comment". * @param int $object_id Object ID. * @param int $user_id Member who reacted. * @param string $type "like" or "bookmark". * @return void */ public static function on_reaction( $object_type, $object_id, $user_id, $type ) { if ( 'like' !== $type ) { return; } if ( 'post' === $object_type ) { $post = Posts::find( $object_id ); if ( $post ) { self::add( $post['user_id'], $user_id, 'like_post', 'post', $post['id'], self::post_data( $post ), 'posts/' . $post['id'] ); } return; } $comment = Comments::find( $object_id ); $post = $comment ? Posts::find( $comment['post_id'] ) : null; if ( $comment && $post ) { self::add( $comment['user_id'], $user_id, 'like_comment', 'post', $post['id'], self::post_data( $post, $comment['message'] ), 'posts/' . $post['id'] ); } } /** * Notifies a member who was invited to a space. * * @param int $space_id Space ID. * @param int $user_id Invited member. * @param int $actor_id Member who invited. * @param string $mode "invite" or "add". * @return void */ public static function on_invited( $space_id, $user_id, $actor_id, $mode ) { $space = SpaceRepository::find( $space_id ); if ( $space && 'invite' === $mode ) { self::add( $user_id, $actor_id, 'space_invite', 'space', $space['id'], self::space_data( $space ), 'spaces/' . $space['slug'] ); } } /** * Notifies space admins about join requests, and members when approved. * * @param int $space_id Space ID. * @param int $user_id Member. * @param string $role New role. * @param string $status New status. * @param array|null $existing Previous membership. * @return void */ public static function on_membership_changed( $space_id, $user_id, $role, $status, $existing ) { unset( $role ); $space = SpaceRepository::find( $space_id ); if ( ! $space ) { return; } $data = self::space_data( $space ); if ( 'pending' === $status && ( ! $existing || 'pending' !== $existing['status'] ) ) { foreach ( self::space_admin_ids( $space_id ) as $admin_id ) { self::add( $admin_id, $user_id, 'space_request', 'space', $space['id'], $data, 'spaces/' . $space['slug'] ); } } if ( 'active' === $status && $existing && 'pending' === $existing['status'] ) { self::add( $user_id, get_current_user_id(), 'space_approved', 'space', $space['id'], $data, 'spaces/' . $space['slug'] ); } } /** * Tells moderators that something waits for review. Moderators get one * unread notice at a time, however many items arrive. * * @return void */ public static function on_review_needed() { foreach ( Moderation::moderator_ids() as $moderator_id ) { self::add( $moderator_id, 0, 'review_needed', 'moderation', 0, array(), 'moderation' ); } } /** * Tells moderators that a new member waits for approval. * * @param int $user_id New member. * @return void */ public static function on_member_pending( $user_id ) { foreach ( Moderation::moderator_ids() as $moderator_id ) { self::add( $moderator_id, $user_id, 'member_pending', 'moderation', 0, array(), 'moderation/members' ); } } /** * Tells authors that their post or comment was approved. * * @param string $type "post" or "comment". * @param array $row Content row. * @param int $moderator_id Moderator. * @param bool $is_new Whether the content is published for the first time. * @return void */ public static function on_content_approved( $type, $row, $moderator_id, $is_new ) { $post = 'post' === $type ? $row : Posts::find( $row['post_id'] ); if ( $post && $is_new ) { self::add( $row['user_id'], $moderator_id, $type . '_approved', 'post', $post['id'], self::post_data( $post, $row['message'] ), 'posts/' . $post['id'] ); } } /** * Tells authors that a moderator removed their post or comment. * * @param string $type "post" or "comment". * @param array $row Content row. * @param array $space Space row. * @param int $moderator_id Moderator. * @return void */ public static function on_content_removed( $type, $row, $space, $moderator_id ) { $data = self::space_data( $space ); $data['excerpt'] = wp_trim_words( wp_strip_all_tags( (string) $row['message'] ), 20, '…' ); self::add( $row['user_id'], $moderator_id, $type . '_removed', 'space', $space['id'], $data, 'spaces/' . $space['slug'] ); } /** * Welcomes members whose account was approved. * * @param int $user_id Member. * @param string $status New status. * @param string $old Previous status. * @param int $moderator_id Moderator. * @return void */ public static function on_member_status_changed( $user_id, $status, $old, $moderator_id ) { if ( 'active' === $status && 'pending' === $old ) { self::add( $user_id, $moderator_id, 'member_approved', 'moderation', 0, array(), '' ); } } /** * Saves a notification and schedules its email. * * Nothing is saved when the member would notify themselves, cannot see * the content, or already has the same unread notification. * * @param int $user_id Recipient. * @param int $actor_id Member who caused it. * @param string $action Action key. * @param string $object_type "post" or "space". * @param int $object_id Object ID. * @param array $data Text shown in the notification. * @param string $route Portal path to open. * @return int Notification ID, or 0. */ public static function add( $user_id, $actor_id, $action, $object_type, $object_id, array $data, $route ) { global $wpdb; $user_id = absint( $user_id ); $actor_id = absint( $actor_id ); if ( ! $user_id || $user_id === $actor_id || ! get_userdata( $user_id ) || SpaceAccess::is_restricted( $user_id ) ) { return 0; } if ( 'post' === $object_type && ! self::can_read_post( $object_id, $user_id ) ) { return 0; } if ( 'event' === $object_type ) { $event = \SpaceBay\Events\Events::find( $object_id ); $space = $event ? \SpaceBay\Spaces\SpaceRepository::find( $event['space_id'] ) : null; if ( ! $space || ! SpaceAccess::can_read( $space, $user_id ) ) { return 0; } } /** * Filters whether a notification is created. * * @since 1.0.0 * * @param bool $create Whether to create it. * @param int $user_id Recipient. * @param string $action Action key. * @param int $actor_id Member who caused it. */ if ( ! apply_filters( 'spacebay_create_notification', true, $user_id, $action, $actor_id ) ) { return 0; } $table = Tables::name( 'notifications' ); $duplicate = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh check before writing. $wpdb->prepare( 'SELECT id FROM %i WHERE user_id = %d AND actor_id = %d AND action = %s AND object_type = %s AND object_id = %d AND is_read = 0', $table, $user_id, $actor_id, $action, $object_type, absint( $object_id ) ) ); if ( $duplicate ) { return 0; } // The email is not sent here: the row is marked and one background job sends what is waiting. $email = Settings::get( 'emails_enabled', true ) && Preferences::wants_email( $user_id, $action ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $table, array( 'user_id' => $user_id, 'actor_id' => $actor_id, 'action' => sanitize_key( $action ), 'object_type' => $object_type, 'object_id' => absint( $object_id ), 'content' => wp_json_encode( $data ), 'url' => $route, 'is_read' => 0, 'email_pending' => $email ? 1 : 0, 'created_at' => Time::now(), ) ); $id = (int) $wpdb->insert_id; if ( $id && $email ) { self::plan_emails(); } /** * Fires after a notification is created. * * @since 1.0.0 * * @param int $id Notification ID. * @param int $user_id Recipient. * @param string $action Action key. */ do_action( 'spacebay_notification_created', $id, $user_id, $action ); return $id; } /** * Lists a member's notifications, newest first. * * @param int $user_id User ID. * @param int $page Page, from 1. * @param int $per_page Items per page. * @param bool $unread_only Only unread ones. * @return array{items: array[], total: int} */ public static function for_user( $user_id, $page = 1, $per_page = 20, $unread_only = false ) { global $wpdb; $table = Tables::name( 'notifications' ); $per_page = max( 1, min( 50, absint( $per_page ) ) ); $offset = ( max( 1, absint( $page ) ) - 1 ) * $per_page; $read_max = $unread_only ? 0 : 1; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Per-user list that changes often. $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(id) FROM %i WHERE user_id = %d AND is_read <= %d', $table, absint( $user_id ), $read_max ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, user_id, actor_id, action, object_type, object_id, content, url, is_read, created_at FROM %i WHERE user_id = %d AND is_read <= %d ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d', $table, absint( $user_id ), $read_max, $per_page, $offset ), ARRAY_A ); // phpcs:enable // Load the people behind the page of notifications together, not one by one. $actor_ids = array_values( array_unique( array_filter( array_map( 'intval', wp_list_pluck( (array) $rows, 'actor_id' ) ) ) ) ); if ( $actor_ids ) { cache_users( $actor_ids ); Profiles::get_many( $actor_ids ); } return array( 'items' => array_map( array( __CLASS__, 'format' ), (array) $rows ), 'total' => $total, ); } /** * Number of unread notifications. * * @param int $user_id User ID. * @return int */ public static function unread_count( $user_id ) { global $wpdb; return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live counter. $wpdb->prepare( 'SELECT COUNT(id) FROM %i WHERE user_id = %d AND is_read = 0', Tables::name( 'notifications' ), absint( $user_id ) ) ); } /** * Marks notifications as read. * * @param int $user_id User ID. * @param int[]|null $ids IDs, or null for all. * @return void */ public static function mark_read( $user_id, $ids = null ) { global $wpdb; $table = Tables::name( 'notifications' ); if ( null === $ids ) { $wpdb->update( $table, array( 'is_read' => 1 ), array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. return; } foreach ( array_slice( array_map( 'absint', $ids ), 0, 100 ) as $id ) { $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $table, array( 'is_read' => 1 ), array( 'id' => $id, 'user_id' => absint( $user_id ), ) ); } } /** * Finds one notification row. * * @param int $id Notification ID. * @return array|null */ public static function find( $id ) { global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single row by primary key. $wpdb->prepare( 'SELECT id, user_id, actor_id, action, object_type, object_id, content, url, is_read, created_at FROM %i WHERE id = %d', Tables::name( 'notifications' ), absint( $id ) ), ARRAY_A ); return $row ? $row : null; } /** * Formats a notification with text in the reader's language. * * @param array $row Row. * @return array */ public static function format( array $row ) { $data = json_decode( (string) $row['content'], true ); $data = is_array( $data ) ? $data : array(); $actor = get_userdata( (int) $row['actor_id'] ); $name = $actor ? $actor->display_name : __( 'Someone', 'spacebay' ); return array( 'id' => (int) $row['id'], 'action' => $row['action'], 'text' => self::text( $row['action'], $name, $data ), 'excerpt' => isset( $data['excerpt'] ) ? (string) $data['excerpt'] : '', 'actor' => array( 'id' => $actor ? (int) $actor->ID : 0, 'name' => $name, 'avatar' => get_avatar_url( $actor ? $actor->ID : 0, array( 'size' => 64 ) ), ), 'route' => (string) $row['url'], 'url' => Portal::link( (string) $row['url'] ), 'is_read' => (bool) $row['is_read'], 'created_at' => Time::iso( $row['created_at'] ), ); } /** * Sentence describing a notification. * * @param string $action Action key. * @param string $name Actor name. * @param array $data Stored data. * @return string */ public static function text( $action, $name, array $data ) { $space = (string) ( $data['space_title'] ?? '' ); switch ( $action ) { case 'mention_post': /* translators: 1: Member name, 2: Space name. */ return sprintf( __( '%1$s mentioned you in a post in %2$s', 'spacebay' ), $name, $space ); case 'mention_comment': /* translators: %s: Member name. */ return sprintf( __( '%s mentioned you in a comment', 'spacebay' ), $name ); case 'comment_post': /* translators: %s: Member name. */ return sprintf( __( '%s commented on your post', 'spacebay' ), $name ); case 'reply_comment': /* translators: %s: Member name. */ return sprintf( __( '%s replied to your comment', 'spacebay' ), $name ); case 'like_post': /* translators: %s: Member name. */ return sprintf( __( '%s liked your post', 'spacebay' ), $name ); case 'like_comment': /* translators: %s: Member name. */ return sprintf( __( '%s liked your comment', 'spacebay' ), $name ); case 'space_invite': /* translators: 1: Member name, 2: Space name. */ return sprintf( __( '%1$s invited you to join %2$s', 'spacebay' ), $name, $space ); case 'space_request': /* translators: 1: Member name, 2: Space name. */ return sprintf( __( '%1$s asked to join %2$s', 'spacebay' ), $name, $space ); case 'space_approved': /* translators: %s: Space name. */ return sprintf( __( 'You are now a member of %s', 'spacebay' ), $space ); case 'review_needed': return __( 'New posts, comments or reports are waiting for review', 'spacebay' ); case 'member_pending': /* translators: %s: Member name. */ return sprintf( __( '%s is waiting for approval to join', 'spacebay' ), $name ); case 'post_approved': /* translators: %s: Space name. */ return sprintf( __( 'Your post in %s was approved', 'spacebay' ), $space ); case 'comment_approved': return __( 'Your comment was approved', 'spacebay' ); case 'post_removed': /* translators: %s: Space name. */ return sprintf( __( 'A moderator removed your post in %s', 'spacebay' ), $space ); case 'comment_removed': /* translators: %s: Space name. */ return sprintf( __( 'A moderator removed your comment in %s', 'spacebay' ), $space ); case 'member_approved': return __( 'Welcome! Your account was approved. You can now join spaces and post.', 'spacebay' ); case 'badge_earned': if ( ! empty( $data['given'] ) ) { /* translators: 1: Member name, 2: Badge name. */ return sprintf( __( '%1$s gave you the “%2$s” badge', 'spacebay' ), $name, (string) ( $data['badge'] ?? '' ) ); } /* translators: %s: Badge name. */ return sprintf( __( 'You earned the “%s” badge', 'spacebay' ), (string) ( $data['badge'] ?? '' ) ); case 'level_up': /* translators: %s: Level name, for example "Regular". */ return sprintf( __( 'You reached a new level: %s', 'spacebay' ), (string) ( $data['level'] ?? '' ) ); case 'event_new': /* translators: 1: Event name, 2: Space name. */ return sprintf( __( 'New event in %2$s: %1$s', 'spacebay' ), (string) ( $data['title'] ?? '' ), $space ); case 'event_updated': /* translators: %s: Event name. */ return sprintf( __( 'The time or place of %s changed', 'spacebay' ), (string) ( $data['title'] ?? '' ) ); case 'event_cancelled': /* translators: %s: Event name. */ return sprintf( __( '%s was cancelled', 'spacebay' ), (string) ( $data['title'] ?? '' ) ); case 'event_reminder': /* translators: %s: Event name. */ return sprintf( __( 'Reminder: %s starts soon', 'spacebay' ), (string) ( $data['title'] ?? '' ) ); } /** * Filters the text of a custom notification action. * * @since 1.0.0 * * @param string $text Text. * @param string $action Action key. * @param string $name Actor name. * @param array $data Stored data. */ return (string) apply_filters( 'spacebay_notification_text', '', $action, $name, $data ); } /** * Removes notifications about a deleted post. * * @param array $post Deleted post row. * @return void */ public static function on_post_deleted( $post ) { global $wpdb; $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'notifications' ), array( 'object_type' => 'post', 'object_id' => (int) $post['id'], ) ); } /** * Removes notifications to and from a deleted user. * * @param int $user_id User ID. * @return void */ public static function on_user_deleted( $user_id ) { global $wpdb; $table = Tables::name( 'notifications' ); $wpdb->delete( $table, array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->delete( $table, array( 'actor_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } /** * Deletes read notifications older than 90 days and all older than 180 days. * * @return void */ public static function cleanup() { global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Daily cron. $wpdb->prepare( 'DELETE FROM %i WHERE ( is_read = 1 AND created_at < %s ) OR created_at < %s', Tables::name( 'notifications' ), gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS ), gmdate( 'Y-m-d H:i:s', time() - 180 * DAY_IN_SECONDS ) ) ); } /** * Whether a member may read a post. * * @param int $post_id Post ID. * @param int $user_id User ID. * @return bool */ private static function can_read_post( $post_id, $user_id ) { $post = Posts::find( $post_id ); $space = $post ? SpaceRepository::find( $post['space_id'] ) : null; return $space && SpaceAccess::can_read( $space, $user_id ); } /** * Data stored for post notifications. * * @param array $post Post row. * @param string $text Text to quote; defaults to the post. * @return array */ private static function post_data( array $post, $text = '' ) { $space = SpaceRepository::find( $post['space_id'] ); $text = '' !== $text ? $text : ( '' !== $post['title'] ? $post['title'] : $post['message'] ); return array( 'space_title' => $space ? $space['title'] : '', 'excerpt' => wp_trim_words( wp_strip_all_tags( $text ), 20, '…' ), ); } /** * Data stored for space notifications. * * @param array $space Space row. * @return array */ private static function space_data( array $space ) { return array( 'space_title' => $space['title'], 'excerpt' => '', ); } /** * Active admins of a space. * * @param int $space_id Space ID. * @return int[] */ private static function space_admin_ids( $space_id ) { $result = Membership::list_members( $space_id, 'active', 1, 50 ); $ids = array(); foreach ( $result['items'] as $row ) { if ( 'admin' === $row['role'] ) { $ids[] = (int) $row['user_id']; } } return $ids; } }