Jake Vanderwerf
2026-05-01 48721c85ebcfa973ee81719d2467ca80e4253dc9
inc/managers/NotificationManager.php
@@ -1,17 +1,14 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\registrar\Registrar;
use WP_Error;
use Exception;
use WP_Post;
use WP_User;
use JVBase\managers\Notifications\Content;
use JVBase\managers\Notifications\EmailDigests;
use JVBase\managers\Notifications\Notifications;
use JVBase\managers\Notifications\Preferences;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
//TODO: Ensure this works with the constants setup
/**
 * NotificationManager - Centralized notification system for edmonton.ink
 *
@@ -21,1747 +18,82 @@
 */
class NotificationManager
{
    protected Cache $userCache; //the individual notifications
    protected Cache $contentCache;  //the 'shared' notifications on new content that has been created
   protected Cache $artistsCache;
   protected Cache $favouritesCache;
   protected Cache $followerCache;
    protected string $campaign;
   protected string $table = BASE.'notifications';
   protected string $contentTable = BASE.'notifications_content';
   protected string $seenTable = BASE.'notifications_user_seen';
   protected string $preferencesTable = BASE.'notification_preferences';
    protected array $notification_types = [
        // System notifications
        'new_favourite'     => [
            'icon'            => 'heart',
            'priority'        => 'low',
            'requires_action' => false,
            'audience'        => 'content_owner',
            'email_digest'    => true
        ],
        'artist_approved'   => [
            'icon'            => 'check',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_joined'     => [
            'icon'              => 'artist',
            'priority'          => 'low',
            'requires_action'   => false,
            'email_digest'      => false,
        ],
        'artist_rejected'   => [
            'icon'            => 'x',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_invitation' => [
            'icon'            => 'invite',
            'priority'        => 'high',
            'requires_action' => true,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'artist_request'    => [
            'icon'            => 'artist',
            'priority'        => 'high',
            'requires_action' => true,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'shop_accepted'     => [
            'icon'            => 'shop',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'shop_rejected'     => [
            'icon'            => 'shop',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true,
        ],
        'new_term'          => [
            'icon'            => 'style',
            'priority'        => 'medium',
            'requires_action' => true,
            'audience'        => 'artists',
            'email_digest'    => false
        ],
        'term_approved'     => [
            'icon'            => 'check',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'term_rejected'     => [
            'icon'            => 'x',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_shared'       => [
            'icon'            => 'share',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_share_accepted' => [
            'icon'            => 'check',
            'priority'        => 'low',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'list_share_revoked' => [
            'icon'            => 'close',
            'priority'        => 'medium',
            'requires_action' => false,
            'audience'        => 'single',
            'email_digest'    => true
        ],
        'system_message'    => [
            'icon'            => 'info',
            'priority'        => 'high',
            'requires_action' => false,
            'audience'        => 'all',
            'email_digest'    => false
        ]
    ];
   protected Notifications $notifications;
   protected Content $content;
   protected EmailDigests $digest;
   protected Preferences $preferences;
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->userCache = Cache::for('userNotifications', WEEK_IN_SECONDS);
        $this->contentCache = Cache::for('contentNotifications', WEEK_IN_SECONDS)->connect('post', true)->connect('taxonomy', true);
        $this->artistsCache = Cache::for('artist', WEEK_IN_SECONDS)->connect('post');
        $this->favouritesCache = Cache::for('favouritedUsers', WEEK_IN_SECONDS)->connect('favourites');
        $this->followerCache = Cache::for('totalFollowers', WEEK_IN_SECONDS)->connect('favourites');
   public function __construct()
   {
      $this->notifications = new Notifications();
      $this->content = new Content();
      $this->digest = new EmailDigests();
      $this->preferences = new Preferences();
   }
        // Add filter for bulk operation handling
        add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3);
        add_action(BASE . 'cleanup_notifications', [$this, 'cleanupOldNotifications']);
        // Track content creation for notifications
        add_action('save_post', [ $this, 'trackContentCreation' ], 10, 3);
        add_action('saved_term', [ $this, 'track_shop_creation' ], 10, 3);
        // Generate content summaries when users log in
        add_action('wp_login', [ $this, 'generateUserContentNotifications' ], 10, 2);
        // Register digest cron jobs
        $this->registerCron();
    }
    /**
     * Registers the digest cron jobs
     * @return void
     */
    protected function registerCron():void
    {
        add_action(BASE . 'notification_digest_daily', [ $this, 'runDailyDigests' ]);
        add_action(BASE . 'notification_digest_weekly', [ $this, 'runWeeklyDigests' ]);
        add_action(BASE . 'notification_digest_monthly', [ $this, 'runMonthlyDigests' ]);
    }
    /************************************************************
     * Basic Notification Methods
     ************************************************************/
    /**
     * @return array
     */
    public function getNotificationTypes():array
    {
        return $this->notification_types;
    }
    /**
     * Add a new notification
     *
     * @param int|array $user_ids Recipient user ID(s)
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function addNotification(
        mixed $user_ids,
        string $type,
        int|null $action_user_id = null,
        string $message = '',
        int|null $target_id = null,
        string|null $target_type = null,
        array|null $context = null
    ):bool|WP_Error {
        // Validate notification type
        if (!isset($this->notification_types[$type])) {
            $this->logError("Invalid notification type: $type", [
                'user_ids' => is_array($user_ids) ? implode(',', $user_ids) : $user_ids,
                'message' => $message
            ], 'warning');
            return new WP_Error('invalid_type', 'Invalid notification type');
        }
        // Handle single user or array of users
        $user_ids = is_array($user_ids) ? $user_ids : [$user_ids];
        if (empty($user_ids)) {
            return false;
        }
        // Queue the bulk notification operation
        $queue = JVB()->queue();
        $queue->queueOperation(
            'addNotification',
            $action_user_id,
            [
                'user_ids' => $user_ids,
                'type' => $type,
                'action_user_id' => $action_user_id,
                'message' => $message,
                'target_id' => $target_id,
                'target_type' => $target_type,
                'context' => $context
            ],
            [
                'count' => count($user_ids),
                'priority' => 'normal',
            'chunk_key' => 'user_ids',
            'chunk_size'=> 50,
                'operation_id' => 'notifications' . uniqid()
            ]
        );
        return true;
    }
    /**
     * Process notification operation
     *
     * @param int|array $user_ids Recipient user ID(s)
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function processNotification(
        int|array $user_ids,
        string $type,
        int|null $action_user_id = null,
        string $message = '',
        int|null $target_id = null,
        string|null $target_type = null,
        array|null $context = null
    ):bool|WP_Error {
        $config = $this->notification_types[$type];
        $notifications = [];
        $errors = [];
        $user_ids = is_array($user_ids) ? $user_ids : [$user_ids];
        global $wpdb;
        foreach ($user_ids as $user_id) {
            // Skip invalid users
            if (!$user_id || $user_id <= 0) {
                $errors[] = "Invalid user ID: $user_id";
                continue;
            }
            // Skip sending notifications to self if action_user_id == user_id
            if ($action_user_id && $action_user_id == $user_id && !($config['notify_self'] ?? false)) {
                continue;
            }
            try {
                // Prepare context data
                $context_json = null;
                if (!empty($context)) {
                    $context_json = json_encode($context);
                }
                // Insert new notification
                $result = $wpdb->insert(
                    $wpdb->prefix . $this->table,
                    [
                        'owner_id' => $user_id,
                        'action_user_id' => $action_user_id,
                        'type' => $type,
                        'status' => 'unread',
                        'message' => $message,
                        'priority' => $config['priority'] ?? 'normal',
                        'target_id' => $target_id,
                        'target_type' => $target_type,
                        'context' => $context_json,
                        'requires_action' => !empty($config['requires_action']) ? 1 : 0,
                        'created_at' => current_time('mysql'),
                        'updated_at' => current_time('mysql')
                    ]
                );
                if ($result) {
                    $notification_id = $wpdb->insert_id;
                    $notifications[] = $notification_id;
                    // Clear cache for this user
                    $this->clearNotificationCache($user_id);
                } else {
                    $errors[] = "Database error for user $user_id: " . $wpdb->last_error;
                    $this->logError("Failed to insert notification", [
                        'user_id' => $user_id,
                        'type' => $type,
                        'db_error' => $wpdb->last_error
                    ]);
                }
            } catch (Exception $e) {
                $errors[] = "Exception for user $user_id: " . $e->getMessage();
                $this->logError("Error adding notification", [
                    'user_id' => $user_id,
                    'type' => $type,
                    'message' => $message,
                    'error' => $e->getMessage()
                ]);
            }
        }
        // Return results
        if (!empty($notifications) && !empty($errors)) {
            $this->logError("Some notifications failed", [
                'successful' => count($notifications),
                'errors' => $errors
            ], 'warning');
        }
        if (empty($notifications) && !empty($errors)) {
            return new WP_Error('notification_failed', 'All notifications failed', [
                'errors' => $errors
            ]);
        }
        return !empty($notifications);
    }
    /**
     * Wrapper for notifying verified artists
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyVerifiedArtists(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getVerified('artist');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyVerifiedPartners(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getVerified('partner');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyEnthusiasts(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getUserIDs('enthusiast');
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /**
     * Wrapper for notifying verified partners
     *
     * @param string $type Notification type
     * @param int|null $action_user_id User who performed the action (optional)
     * @param string $message Notification message (optional)
     * @param int|null $target_id Related target ID (optional)
     * @param string|null $target_type Related target type (optional)
     * @param array|null $context Additional contextual data (optional)
     *
     * @return bool|WP_Error Success or error
     */
    public function notifyEveryone(string $type, int|null $action_user_id = null, string $message = '', int|null $target_id = null, string|null $target_type = null, array|null $context = null):bool|WP_Error
    {
        $artists = $this->getUserIDs(Registrar::getRegistered('user'));
        return $this->addNotification($artists, $type, $action_user_id, $message, $target_id, $target_type, $context);
    }
    /************************************************************
     * Content Notification Methods
     ************************************************************/
    /**
     * Track content creation or updates for notification purposes
     *
     * @param int $post_id Post ID
     * @param WP_Post $post Post object
     * @param bool $update Whether this is an update
     * @return void
     */
    public function trackContentCreation(int $post_id, WP_POST $post, bool $update):void
    {
      // SAFETY: Skip attachments and other non-content post types
      if (in_array($post->post_type, jvbIgnoredPostTypes())) {
         return;
      }
        // Skip if not a published post
        if ($post->post_status !== 'publish') {
            return;
        }
        // Check if this is a relevant content type
        $content_types = array_map(function($type) {return jvbCheckBase($type->getSlug()); }, Registrar::getFeatured('show_feed', 'post'));
        if (!in_array($post->post_type, $content_types)) {
            return;
        }
        // Check if the artist has any followers before tracking
        $follower_count = $this->getFollowerCount($post->post_author);
        if ($follower_count === 0) {
            return; // No need to track if nobody follows this artist
        }
        // Get the clean content type for storing
        $content_type = str_replace(BASE, '', $post->post_type);
        // Update the artist's content notification records
        $this->updateContentNotificationRecord($post->post_author, $content_type, $post_id, $update);
    }
    /**
     * Update content notification record for an artist
     *
     * @param int $artist_id Artist user ID
     * @param string $content_type Content type (tattoo, artwork, etc)
     * @param int $content_id Content post ID
     * @param bool $is_update Whether this is an update to existing content
     *
     * @return bool Success or failure
     */
    protected function updateContentNotificationRecord(int $artist_id, string $content_type, int $content_id, bool $is_update = false):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Start transaction
        $wpdb->query('START TRANSACTION');
        $success = false;
        try {
            // Get today's date
            $today = date('Y-m-d');
            $frequency_updates = [];
            // Update records for each frequency
            foreach (['daily', 'weekly', 'monthly'] as $frequency) {
                // Find or create a record for this artist, date and frequency
                $record = $wpdb->get_row($wpdb->prepare(
                    "SELECT * FROM {$table}
                 WHERE user_id = %d AND date = %s AND frequency = %s",
                    $artist_id,
                    $today,
                    $frequency
                ));
                if ($record) {
                    $frequency_updates[$frequency] = $this->updateExistingContentRecord(
                        $record,
                        $content_type,
                        $content_id,
                        $is_update
                    );
                } else {
                    $frequency_updates[$frequency] = $this->createNewContentRecord(
                        $artist_id,
                        $frequency,
                        $content_type,
                        $content_id,
                        $is_update
                    );
                }
            }
            // Check if all updates were successful
            $success = !in_array(false, $frequency_updates, true);
            if ($success) {
                $wpdb->query('COMMIT');
                return true;
            } else {
                // If any update failed, roll back
                $wpdb->query('ROLLBACK');
                $this->logError("Failed to update content notification records", [
                    'artist_id' => $artist_id,
                    'content_type' => $content_type,
                    'content_id' => $content_id,
                    'updates' => $frequency_updates
                ]);
                return false;
            }
        } catch (Exception $e) {
            $wpdb->query('ROLLBACK');
            $this->logError("Exception in content notification update", [
                'artist_id' => $artist_id,
                'content_type' => $content_type,
                'content_id' => $content_id,
                'error' => $e->getMessage()
            ]);
            return false;
        }
    }
    /**
     * Update an existing content notification record
     *
     * @param object $record Existing database record
     * @param string $content_type Content type
     * @param int $content_id Content ID
     * @param bool $is_update Whether this is an update
     *
     * @return bool Success or failure
     */
    protected function updateExistingContentRecord(object $record, string $content_type, int $content_id, bool $is_update):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Decode existing JSON data
        $new_items     = json_decode($record->new_items, true) ?: [];
        $updated_items = json_decode($record->updated_items, true) ?: [];
        // Initialize arrays if not present
        if (!isset($new_items[BASE.$content_type])) {
            $new_items[BASE.$content_type] = [];
        }
        if (!isset($updated_items[BASE.$content_type])) {
            $updated_items[BASE.$content_type] = [];
        }
        // Add ID to appropriate array
        if ($is_update) {
            // Add to updated array if not already there and not in new items
            if (!in_array($content_id, $updated_items[BASE.$content_type]) &&
                 !in_array($content_id, $new_items[BASE.$content_type])) {
                $updated_items[BASE.$content_type][] = $content_id;
            }
        } else {
            // Add to new array if not already there
            if (!in_array($content_id, $new_items[BASE.$content_type])) {
                $new_items[BASE.$content_type][] = $content_id;
            }
        }
        // Prepare update data
        $update_data = [
            'new_items'     => json_encode($new_items),
            'updated_items' => json_encode($updated_items),
            'updated_at'    => current_time('mysql')
        ];
        // Update appropriate count column
        $count_column = "{$content_type}_count";
        if (property_exists($record, $count_column)) {
            $update_data[ $count_column ] = $record->$count_column + ( $is_update ? 0 : 1 );
        }
        // Update total count
        $update_data['total_items'] = $record->total_items + ( $is_update ? 0 : 1 );
        // Update the record
        return $wpdb->update(
            $table,
            $update_data,
            [ 'id' => $record->id ]
        ) !== false;
    }
    /**
     * Create a new content notification record
     *
     * @param int $artist_id Artist user ID
     * @param string $frequency Frequency (daily, weekly, monthly)
     * @param string $content_type Content type
     * @param int $content_id Content ID
     * @param bool $is_update Whether this is an update
     *
     * @return bool Success or failure
     */
    protected function createNewContentRecord(int $artist_id, string $frequency, string $content_type, int $content_id, bool $is_update):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->contentTable;
        // Initialize arrays for new and updated items
        $new_items     = [];
        $updated_items = [];
        if ($is_update) {
            $updated_items[BASE.$content_type] = [ $content_id ];
        } else {
            $new_items[BASE.$content_type] = [ $content_id ];
        }
        // Prepare insert data
        $insert_data = [
            'user_id'       => $artist_id,
            'date'          => date('Y-m-d'),
            'frequency'     => $frequency,
            'total_items'   => 1,
            'new_items'     => json_encode($new_items),
            'updated_items' => json_encode($updated_items),
            'created_at'    => current_time('mysql'),
            'updated_at'    => current_time('mysql')
        ];
        // Set count column
        $insert_data["{$content_type}_count"] = $is_update ? 0 : 1;
        // Insert the record
        return $wpdb->insert($table, $insert_data) !== false;
    }
    /**
     * Generate content notifications for a user upon login
     *
     * @param string $username Username
     * @param WP_User $user User object
     * @return void
     */
    public function generateUserContentNotifications(string $username, WP_User $user):void
    {
        $this->createContentSeenRecords($user->ID);
    }
    /**
     * Create content seen records for a user's followed artists
     *
     * @param int $user_id User ID
     *
     * @return int Number of notifications created
     */
    protected function createContentSeenRecords(int $user_id):int
    {
        global $wpdb;
        try {
            // Get followed artists
            $followed_artists = $this->getFollowedArtists($user_id);
            if (empty($followed_artists)) {
                return 0;
            }
            // Get the last time notifications were checked
            $last_check = get_user_meta($user_id, BASE . 'last_content_check', true);
            $since_date = $last_check ? date('Y-m-d', strtotime($last_check)) : date('Y-m-d', strtotime('-7 days'));
            // Create placeholders for SQL query
            $placeholders = implode(',', array_fill(0, count($followed_artists), '%d'));
            // Get content notifications since last check
            $content_records = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM {$wpdb->prefix}{$this->contentTable}
                     WHERE user_id IN ($placeholders)
                     AND date >= %s
                     AND total_items > 0",
                    array_merge($followed_artists, [ $since_date ])
                )
            );
            if (empty($content_records)) {
                return 0;
            }
            // Add user seen records for each content notification
            $count           = 0;
            $user_seen_table = $wpdb->prefix . $this->seenTable;
            foreach ($content_records as $record) {
                // Check if record already exists
                $exists = $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$user_seen_table}
                         WHERE user_id = %d AND content_notification_id = %d",
                        $user_id,
                        $record->id
                    )
                );
                if (!$exists) {
                    // Create new seen record
                    $wpdb->insert(
                        $user_seen_table,
                        [
                            'user_id'                 => $user_id,
                            'content_notification_id' => $record->id,
                            'status'                  => 'unread',
                            'created_at'              => current_time('mysql')
                        ]
                    );
                    $count ++;
                }
            }
            // Update last check time
            update_user_meta($user_id, BASE . 'last_content_check', current_time('mysql'));
            // Clear cache
            $this->clearNotificationCache($user_id);
            return $count;
        } catch (Exception $e) {
            $this->logError("Error creating content seen records: " . $e->getMessage(), [
                'user_id' => $user_id
            ]);
            return 0;
        }
    }
    /************************************************************
     * Notification Digest Methods
     ************************************************************/
    /**
     * Process daily notification digests
     */
    public function runDailyDigests():void
    {
        $this->campaign = 'daily_digest_' . date('Y-m-d');
        $this->processDigest('daily');
    }
    /**
     * Process weekly notification digests
     */
    public function runWeeklyDigests():void
    {
        $this->campaign = 'weekly_digest_' . date('Y-m-d');
        $this->processDigest('weekly');
    }
    /**
     * Process monthly notification digests
     */
    public function runMonthlyDigests():void
    {
        $this->campaign = 'monthly_digest_' . date('Y-m-d');
        $this->processDigest('monthly');
    }
    /**
     * Process digests for a specific frequency
     *
     * @param string $frequency Digest frequency (daily, weekly, monthly)
     *
     * @return bool Success or failure
     */
    protected function processDigest(string $frequency):bool
    {
        // Get users with this digest frequency preference
        $users = $this->getUsersForDigest($frequency);
        if (empty($users)) {
            return true; // No users to process
        }
        // Queue digest generation
        $queue = JVB()->queue();
        $queue->queueOperation(
            'email_notification_digest',
            0, // System user
            [
                'frequency' => $frequency,
                'users'     => $users
            ],
            [
                'count' => count($users),
            'chunk_key' => 'users',
            'chunk_size' => 20,
                'priority'        => 'normal',
                'operation_id'    => 'notification_digest_' . date('Y_m_d')
            ]
        );
        return true;
    }
    /**
     * Get users who have subscribed to a specific digest frequency
     *
     * @param string $frequency Digest frequency
     *
     * @return array Array of user IDs
     */
    protected function getUsersForDigest(string $frequency):array
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->preferencesTable;
        // Get users with this frequency setting
        $users = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT user_id FROM $table
                 WHERE frequency = %s",
                $frequency
            )
        );
        return $users ?: [];
    }
    /**
     * Generate and send a digest email for a user
     *
     * @param int $user_id User ID
     * @param string $frequency Digest frequency
     *
     * @return bool Success status
     */
    public function generateUserDigest(int $user_id, string $frequency):bool
    {
        try {
            $user = get_userdata($user_id);
            if (!$user || !is_email($user->user_email)) {
                return false;
            }
            // Get date range based on frequency
            $today      = date('Y-m-d');
            $since_date = $this->getSinceDate($frequency, $today);
            // Get regular notifications
            $notifications = $this->getDigestNotifications($user_id);
            // Get content updates from followed artists
            $content_updates = $this->getContentUpdatesForDigest($user_id, $since_date);
            if (empty($notifications) && empty($content_updates)) {
                return true; // Nothing to send
            }
            // Generate and send email
            $sent = $this->sendDigestEmail($user, $frequency, $notifications, $content_updates);
            if ($sent) {
                // Update preferences last_sent timestamp
                $this->updateDigestTimestamps($user_id, $frequency);
                return true;
            }
            return false;
        } catch (Exception $e) {
            $this->logError("Error generating digest for user $user_id: " . $e->getMessage());
            return false;
        }
    }
    /**
     * Get notifications for a digest
     *
     * @param int $user_id User ID
     *
     * @return array Notification objects
     */
    protected function getDigestNotifications(int $user_id):array
    {
        global $wpdb;
        // Get unread, non-emailed notifications
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}{$this->table}
                 WHERE user_id = %d
                 AND status = 'unread'
                 AND emailed_at IS NULL
                 ORDER BY priority DESC, created_at DESC",
                $user_id
            )
        );
    }
    /**
     * Get content updates for digest
     *
     * @param int $user_id User ID
     * @param string $since_date Date string (YYYY-MM-DD)
     *
     * @return array Content update records
     */
    protected function getContentUpdatesForDigest(int $user_id, string $since_date):array
    {
        // Get followed artists
        $followed_artists = $this->getFollowedArtists($user_id);
        if (empty($followed_artists)) {
            return [];
        }
        global $wpdb;
        // Create placeholders for SQL IN clause
        $placeholders = implode(',', array_fill(0, count($followed_artists), '%d'));
        // Get content records since the date
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}{$this->contentTable}
                 WHERE user_id IN ($placeholders)
                 AND date >= %s
                 AND total_items > 0
                 ORDER BY date DESC",
                array_merge($followed_artists, [ $since_date ])
            )
        );
    }
    /**
     * Get since date for a frequency
     *
     * @param string $frequency Digest frequency
     * @param string $today Today's date
     *
     * @return string Date string (YYYY-MM-DD)
     */
    protected function getSinceDate(string $frequency, string $today):string
    {
        switch ($frequency) {
            case 'daily':
                return date('Y-m-d', strtotime('-1 day', strtotime($today)));
            case 'weekly':
                return date('Y-m-d', strtotime('-1 week', strtotime($today)));
            case 'monthly':
                return date('Y-m-d', strtotime('-1 month', strtotime($today)));
            default:
                return '';
        }
    }
    /**
     * Send a digest email
     *
     * @param WP_User $user User object
     * @param string $frequency Digest frequency
     * @param array $notifications Regular notifications
     * @param array $content_updates Content update records
     *
     * @return bool Whether the email was sent
     */
    protected function sendDigestEmail(WP_User $user, string $frequency, array $notifications, array $content_updates):bool
    {
        // Generate subject based on frequency
        $subjects = [
            'daily'   => [
                "[edmonton.ink] Your " . date('l') . " Ink Update â™¡",
                "[edmonton.ink] Fresh Ink Alert - Your " . date('l') . " Digest",
                "[edmonton.ink] What You Missed in Edmonton's Tattoo Scene Today"
            ],
            'weekly'  => [
                "[edmonton.ink] Your Weekly Roundup from edmonton.ink â™¡",
                "[edmonton.ink] This Week in Edmonton's Tattoo Scene",
                "[edmonton.ink] Weekly Ink Update - Fresh From the Scene"
            ],
            'monthly' => [
                "[edmonton.ink] Monthly Ink Roundup â™¡",
                "[edmonton.ink] Your Monthly Scene Report: " . date('F') . " Edition",
                "[edmonton.ink] " . date('F') . " in Edmonton Tattoos"
            ]
        ];
        // Randomly select a subject for variety
        $subject_options = $subjects[ $frequency ] ?? [ "Your edmonton.ink Update" ];
        $subject         = $subject_options[ array_rand($subject_options) ];
        // Generate email content
        $content = $this->generateDigestContent($user, $frequency, $notifications, $content_updates);
        // Add tracking pixel
        $tracking_code = sprintf(
            '<img src="%s/track-email.php?uid=%s&digest=%s" width="1" height="1" alt="" />',
            site_url(),
            base64_encode($user->ID),
            $frequency
        );
        $content       .= $tracking_code;
        // Set header based on frequency
        $header = match ($frequency) {
            'daily' => "TODAY'S INK DROP",
            'weekly' => "THIS WEEK'S SCENE REPORT",
            'monthly' => "MONTHLY INK ROUNDUP",
            default => "YOUR INK UPDATES",
        };
        // Send the email
        return JVB()->email()->sendEmail($user->user_email, $subject, $content, $header);
    }
    /**
     * Generate HTML content for digest email
     *
     * @param WP_User $user User object
     * @param string $frequency Digest frequency
     * @param array $notifications Regular notifications
     * @param array $content_updates Content update records
     *
     * @return string HTML email content
     */
   protected function generateDigestContent(WP_User $user, string $frequency, array $notifications, array $content_updates):string
   /************************************************
   * PREFERENCES
   ************************************************/
   public function getUsersByFrequency(string $frequency):array
   {
      $content = sprintf('<p>Hey %s,</p>', $user->first_name ?: $user->display_name);
      // Intro text based on frequency
      switch ($frequency) {
         case 'daily':
            $content .= '<p>Here\'s what happened in Edmonton\'s tattoo scene today:</p>';
            break;
         case 'weekly':
            $content .= '<p>Here\'s what you missed in Edmonton\'s tattoo scene this week:</p>';
            break;
         case 'monthly':
            $content .= sprintf('<p>Here\'s your monthly roundup of what happened in %s in Edmonton\'s tattoo scene:</p>', date('F'));
            break;
      }
      // Process artist content updates - the most visually interesting part
      $content .= $this->generateContentUpdatesSection($content_updates);
      // Process regular notifications
      if (!empty($notifications)) {
         $content .= $this->generateNotificationsSection($notifications);
      }
      // Add footer content
      $content .= JVB()->email()->divider();
      $settings_url = add_query_arg([
         'utm_source'   => 'email',
         'utm_medium'   => 'digest',
         'utm_campaign' => $this->campaign
      ], site_url('/dash/settings/'));
      $content .= sprintf(
         '<p>You\'re receiving this %s digest because you follow artists on edmonton.ink. You can %s at any time.</p>',
         $frequency,
         '<a href="' . esc_url($settings_url) . '">adjust your notification settings</a>'
      );
      return $content;
      return $this->preferences->getUsersByFrequency($frequency);
   }
    /**
     * Generate HTML section for content updates
     *
     * @param array $content_updates Content update records
     *
     * @return string HTML content
     * TODO: This needs some work
     */
    protected function generateContentUpdatesSection(array $content_updates):string
    {
        if (empty($content_updates)) {
            return '';
        }
        $content = '';
        $cache   = Cache::for('digest_content', HOUR_IN_SECONDS * 6); // Cache for 6 hours
        // Group updates by artist
        $updates_by_artist = [];
        foreach ($content_updates as $update) {
            if (!isset($updates_by_artist[$update->user_id])) {
                $updates_by_artist[ $update->user_id ] = [];
            }
            $updates_by_artist[ $update->user_id ][] = $update;
        }
        // Process each artist's updates
        foreach ($updates_by_artist as $artist_id => $updates) {
            $artist_data = $this->getArtistData($artist_id);
            if (!$artist_data) {
                continue;
            }
            // Combine all content updates from this artist
            $combined_new_items = [];
            foreach ($updates as $update) {
                $new_items = json_decode($update->new_items, true) ?: [];
                foreach ($new_items as $type => $ids) {
                    if (!isset($combined_new_items[ $type ])) {
                        $combined_new_items[ $type ] = [];
                    }
                    $combined_new_items[ $type ] = array_merge($combined_new_items[ $type ], $ids);
                    // Remove duplicates
                    $combined_new_items[ $type ] = array_unique($combined_new_items[ $type ]);
                }
            }
            // Skip if no content to show
            if (empty($combined_new_items)) {
                continue;
            }
            // Add artist header
            $content .= sprintf(
                '<h3><a href="%s" class="text-link">%s</a></h3>',
                esc_url(add_query_arg([
                    'utm_source'   => 'email',
                    'utm_medium'   => 'digest',
                    'utm_campaign' => $this->campaign
                ], $artist_data['url'])),
                esc_html($artist_data['display_name'])
            );
            // Process each content type
            foreach ($combined_new_items as $type => $ids) {
                if (empty($ids)) {
                    continue;
                }
                $clean_type = str_replace(BASE, '', $type);
                $type_label = $this->getContentTypeLabel($clean_type);
                // Add subheading for content type
                $content .= sprintf('<h4>%s</h4>', esc_html($type_label));
                // Display up to 3 items in a row
                $display_ids = array_slice($ids, 0, 3);
                $content     .= '<table><tr>';
                foreach ($display_ids as $item_id) {
                    // Get cached item data or fetch it
                    $cache_key = "digest_{$type}_{$item_id}";
                    $item_data = $cache->get($cache_key);
                    if ($item_data === false) {
                        $item_data = $this->getContentDetails($type, $item_id, $artist_id);
                        if ($item_data) {
                            $cache->set($cache_key, $item_data);
                        }
                    }
                    if (!$item_data) {
                        continue;
                    }
                    // Add item to email
                    $content .= '<td style="padding: 10px; width: 33.3%; vertical-align: top; text-align: center;">';
                    $content .= '<div style="margin-bottom: 10px;">';
                    $content .= sprintf(
                        '<a href="%s"><img src="%s" alt="%s" style="width: 100%%; max-width: 150px; height: auto; border-radius: 4px;"></a>',
                        esc_url(add_query_arg([
                            'utm_source'   => 'email',
                            'utm_medium'   => 'digest',
                            'utm_campaign' => $this->campaign
                        ], $item_data['url'])),
                        esc_url($item_data['image']),
                        esc_attr($item_data['title'])
                    );
                    $content .= '</div>';
                    $content .= sprintf('<p style="margin: 5px 0; font-weight: bold;">%s</p>', esc_html($item_data['title']));
                    $content .= '</td>';
                }
                // Fill empty cells if needed
                for ($i = count($display_ids); $i < 3; $i++) {
                    $content .= '<td style="width: 33.3%;"></td>';
                }
                $content .= '</tr></table>';
                // Add "See all" link if there are more items
                if (count($ids) > 3) {
                    $content .= sprintf(
                        '<p style="text-align: right;"><a href="%s" class="text-link">See all %d %s â†’</a></p>',
                        esc_url(add_query_arg([
                            'utm_source'   => 'email',
                            'utm_medium'   => 'digest',
                            'utm_campaign' => $this->campaign
                        ], site_url('/dash/feed/?type=' . $clean_type . '&artist=' . $artist_id))),
                        count($ids),
                        $this->pluralize($clean_type)
                    );
                }
            }
            $content .= '<div class="divider"></div>';
        }
        return $content;
    }
    /**
     * Generate HTML section for regular notifications
     *
     * @param array $notifications Notification objects
     *
     * @return string HTML content
     */
   protected function generateNotificationsSection(array $notifications):string
   public function getUserSubscriptions(int $userID, string $frequency):array
   {
      if (empty($notifications)) {
         return '';
      }
      $items = [];
      // Group notifications by type
      $by_type = [];
      foreach ($notifications as $notification) {
         if (!isset($by_type[$notification->type])) {
            $by_type[$notification->type] = [];
         }
         $by_type[$notification->type][] = $notification;
      }
      // Process each type
      foreach ($by_type as $type => $type_notifications) {
         foreach ($type_notifications as $notification) {
            $message = $notification->message;
            if (empty($message)) {
               $message = $this->generateNotificationMessage($notification);
            }
            if (!empty($message)) {
               $items[] = ['label' => '', 'value' => $message];
            }
         }
      }
      return JVB()->email()->table($items, 'Other Updates');
      return $this->preferences->getUserSubscriptions($userID, $frequency);
   }
    /**
     * Generate a message for a notification when none is provided
     *
     * @param object $notification Notification object
     *
     * @return string Formatted message
     */
    protected function generateNotificationMessage(object $notification):string
    {
        switch ($notification->type) {
            case 'new_favourite':
                return 'Someone favourited your content';
            case 'artist_approved':
                return 'Your artist profile has been approved';
            case 'artist_invitation':
                return 'You have a new invitation to join a shop';
            case 'new_term':
                return 'New term suggestion requires your approval';
            case 'term_approved':
                return 'Your term suggestion was approved';
            case 'term_rejected':
                return 'Your term suggestion was not approved';
            case 'list_shared':
                return 'Someone shared a list with you';
            default:
                return 'You have a new notification';
        }
    }
    /**
     * Get content details for digest
     *
     * @param string $type Content type (with jvb_ prefix)
     * @param int $content_id Content ID
     * @param int $artist_id Artist ID
     *
     * @return array|false Content details or false if not found
     */
    protected function getContentDetails(string $type, int $content_id, int $artist_id):array|false
    {
        // Build post type from content type
        $post_type = $type; // Already has jvb_ prefix
        // Get the post
        $post = get_post($content_id);
        if (!$post || $post->post_type !== $post_type || $post->post_status !== 'publish') {
            return false;
        }
        // Get artist data
        $artist_data = $this->getArtistData($artist_id);
        if (!$artist_data) {
            return false;
        }
        // Get featured image if available
        $image_url = get_the_post_thumbnail_url($content_id, 'medium');
        if (!$image_url) {
            // Try meta fields for image
            $meta_image_id = get_post_meta($content_id, BASE . 'image', true);
            if ($meta_image_id !== '') {
                $image_url = wp_get_attachment_image_url($meta_image_id, 'medium');
            }
        }
        if (!$image_url) {
            return false; // Skip items without images for digest
        }
        return [
            'id'          => $content_id,
            'title'       => $post->post_title,
            'url'         => get_permalink($content_id),
            'image'       => $image_url,
            'date'        => $post->post_date,
            'artist_id'   => $artist_id,
            'artist_name' => $artist_data['display_name'],
            'artist_url'  => $artist_data['url'],
            'type'        => str_replace(BASE, '', $type)
        ];
    }
    /**
     * Get human-readable label for content type
     *
     * @param string $type Content type (without jvb_ prefix)
     *
     * @return string Label
     */
    protected function getContentTypeLabel(string $type):string
    {
        $labels = [
            'tattoo'   => 'New Tattoos',
            'artwork'  => 'New Artwork',
            'piercing' => 'New Piercings',
            'event'    => 'Upcoming Events',
            'news'     => 'News & Updates',
            'offer'    => 'Special Offers'
        ];
        return $labels[$type] ?? ucfirst($type . 's');
    }
    /**
     * Update digest timestamps for notification preferences
     *
     * @param int $user_id User ID
     * @param string $frequency Digest frequency
     *
     * @return bool Success or failure
     */
    protected function updateDigestTimestamps(int $user_id, string $frequency):bool
    {
        global $wpdb;
        $table = $wpdb->prefix . $this->preferencesTable;
        $result = $wpdb->update(
            $table,
            [
                'last_sent' => current_time('mysql')
            ],
            [
                'user_id'   => $user_id,
                'frequency' => $frequency
            ]
        );
        return $result !== false;
    }
    /************************************************************
     * Bulk Operation Handlers
     ************************************************************/
    /**
     * Handle bulk operations for notifications
     *
     * @param WP_Error|array $result Default result
     * @param object $operation Operation object
     * @param array $data Current item
     *
     * @return WP_Error|array Operation result
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        switch ($operation->type) {
            case 'email_notification_digest':
                return $this->handleDigestBatch($operation, $data);
            case 'addNotification':
                return $this->handleAddNotificationOperation($operation, $data);
            default:
                return $result;
        }
    }
    /**
     * Handle digest batch operation
     *
     * @param object $operation Operation object
     * @param array $data
     *
     * @return WP_Error|array|bool Operation result
     */
    protected function handleDigestBatch(object $operation, array $data):WP_Error|array|bool
    {
        try {
            // Process one user at a time
            $user_id = $data['users'][ $operation->progress_count ] ?? null;
            if (!$user_id) {
                return [
                    'success' => false,
                    'message' => 'Invalid user ID'
                ];
            }
            $result = $this->generateUserDigest($user_id, $data['frequency']);
            return [
                'success' => $result,
                'result' => $result ? 'Digest sent successfully' : 'Failed to send digest',
            ];
        } catch (Exception $e) {
            $this->logError("Error processing digest batch: " . $e->getMessage(), [
                'operation_id' => $operation->id
            ]);
            return false;
        }
    }
    /**
     * @param object $operation
     * @param array $data
     *
     * @return WP_Error|array|bool
     */
    protected function handleAddNotificationOperation(object $operation, array $data):WP_Error|array|bool
    {
        try {
            $user_ids = $data['user_ids'] ?? [];
            $type = $data['type'] ?? '';
            $message = $data['message'] ?? '';
            $target_id = $data['target_id'] ?? null;
            $target_type = $data['target_type'] ?? null;
            if (empty($user_ids) || empty($type)) {
                return new WP_Error('invalid_data', 'Missing required notification data');
            }
            foreach ($user_ids as $key => $user_id) {
                if (!$this->checkUser($user_id)) {
                    unset($user_ids[$key]);
                }
            }
            // Add notification for this user
            $result = $this->processNotification($user_ids, $type, $message, $target_id, $target_type);
            return [
                'success' => !is_wp_error($result),
                'result' => is_wp_error($result) ? $result->get_error_message() : $result,
            ];
        } catch (Exception $e) {
            return [
            'success'   => false,
            'result' =>  $e->getMessage()
         ];
        }
    }
    /**
     * @param int $user_id
     *
     * @return array|false|mixed
     */
    protected function getArtistData(int $user_id):array|false
    {
        // Try to get from cache
      $artist_id = get_user_meta($user_id, BASE . 'link', true);
      if (!$artist_id || $artist_id === '') {
         return false;
      }
        $cached = $this->artistsCache->get($artist_id);
        if ($cached !== false) {
            return $cached;
        }
        // Get basic artist data
        $artist_post = get_post($artist_id);
        if (!$artist_post) {
            return false;
        }
        $data = [
            'id' => $artist_id,
            'user_id' => $user_id,
            'display_name' => get_the_title($artist_id),
            'first_name' => get_post_meta($artist_id, BASE . 'first_name', true),
            'url' => get_permalink($artist_id),
            'image' => get_post_thumbnail_id($artist_id)
        ];
        // Cache the result
        $this->artistsCache->set($artist_id, $data);
        return $data;
    }
    /**
     * @param int $user_id
     *
     * @return array
     */
    protected function getFollowedArtists(int $user_id):array
    {
      return $this->favouritesCache->remember(
         $user_id,
         function() use ($user_id) {
            global $wpdb;
            $favourites_table = $wpdb->prefix . BASE . 'favourites';
            // Get artists this user has favourited
            return $wpdb->get_col($wpdb->prepare(
            "SELECT f.target_id
                FROM {$favourites_table} f
                JOIN {$wpdb->posts} p ON f.target_id = p.ID
                WHERE f.user_id = %d
                AND f.type = 'artist'
                AND p.post_status = 'publish'",
               $user_id
            ));
         }
      );
    }
    /**
     * @param int $artist_id
     *
     * @return int
     */
    protected function getFollowerCount(int $artist_id):int
    {
      return $this->followerCache->remember(
         $artist_id,
         function() use ($artist_id) {
            global $wpdb;
            $favourites_table = $wpdb->prefix . BASE . 'favourites';
            return $wpdb->get_var($wpdb->prepare(
               "SELECT COUNT(DISTINCT user_id)
                FROM {$favourites_table}
                WHERE target_id = %d AND type = 'artist'",
               $artist_id
            ));
         }
      );
    }
    /**
     * @param string $word
     *
     * @return string
     */
    protected function pluralize(string $content):string
    {
      $registrar = Registrar::getInstance($content);
      return ($registrar) ? $registrar->getPlural()
         : str_replace('_', ' ', $content.'s');
    }
    /**
     * @param int $user_id
     *
     * @return void
     */
    protected function clearNotificationCache(int $user_id):void
    {
      $this->userCache->forget($user_id);
      $this->contentCache->forget($user_id);
    }
    /**
     * Log errors with proper context using the error handler
     *
     * @param string $message Error message
     * @param array $context Additional context data
     * @param string $severity Error severity (debug, info, warning, error, critical)
     * @return void
     */
    protected function logError(string $message, array $context = [], string $severity = 'error'):void
    {
        try {
            // Use the ErrorHandler through the JVB singleton
            JVB()->error()->log(
                'notifications', // component
                $message,
                $context,
                $severity
            );
        } catch (Exception $e) {
            // Fallback if error handler fails
            error_log("NotificationManager Error: $message - " . json_encode($context));
        }
    }
    /**
     * @return void
     */
    public function cleanupOldNotifications():void
    {
        global $wpdb;
        $notifications_table = $wpdb->prefix . $this->table;
        $seen_table = $wpdb->prefix . $this->seenTable;
        // Keep track of current time
        $current_time = current_time('mysql');
        // Delete regular notifications older than 3 months
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$notifications_table}
         WHERE created_at < DATE_SUB(%s, INTERVAL 3 MONTH)
         AND status IN ('read', 'actioned', 'dismissed')",
            $current_time
        ));
        // Delete dismissed content notifications older than 1 month
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$seen_table}
         WHERE status = 'dismissed'
         AND created_at < DATE_SUB(%s, INTERVAL 1 MONTH)",
            $current_time
        ));
        // Delete read content notifications older than 3 months
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$seen_table}
         WHERE status = 'read'
         AND created_at < DATE_SUB(%s, INTERVAL 3 MONTH)",
            $current_time
        ));
    }
    /**
     * @return array
     */
    protected function getVerified(string|array $userRoles):array
    {
      $userRoles = $this->checkRoles($userRoles);
      if (empty($userRoles)) {
         return [];
      }
      $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user',true);
      return $cache->remember(
         'verified',
         function() use ($userRoles) {
            return get_users([
               'role'          => $userRoles,
               'capability'    => 'skip_moderation',
               'fields'        => 'ID'
            ]);
         }
      );
    }
   protected function getUserIDs(array|string $roles):array
   public function addUserPreference(int $userID, int $item_id, string $item_type, string $frequency):bool
   {
      $roles = $this->checkRoles($roles);
      if (empty($roles)) {
         return [];
      }
      $cache = Cache::for('everyone', DAY_IN_SECONDS)->connect('user', true);
      return $cache->remember(
         $cache->generateKey($roles),
         function() use ($roles) {
            return get_users([
               'role'   => $roles,
               'fields' => 'ID'
            ]);
         }
      );
      return $this->preferences->addUserPreference($userID, $item_id, $item_type, $frequency);
   }
   protected function checkRoles(string|array $roles):array
   public function deleteUserPreference(int $userID, int $item_id, string $item_type):bool
   {
      if (!is_array($roles)) {
         $roles = explode(',',$roles);
      }
      return array_map(function ($r) {
         return jvbCheckBase(trim($r));
      }, array_filter($roles, function ($r) {
         return Registrar::getInstance(trim($r))!== false;
      }));
      return $this->preferences->deleteUserPreference($userID, $item_id, $item_type);
   }
   /************************************************
   * NOTIFICATIONS
   ************************************************/
   public function notify(int|array $user_ids, string $notification_type, int $fromUser = 0, array $args = []):bool
   {
      return $this->notifications->notify($user_ids, $notification_type, $fromUser, $args);
   }
   public function unnotify(int|array $user_ids, string $notification_type, int $fromUser = 0, array $args = []):bool
   {
      return $this->notifications->unnotify($user_ids, $notification_type, $fromUser, $args);
   }
   public function getUserNotifications(int $user_id, array $args = []):array
   {
      return $this->notifications->getUserNotifications($user_id, $args);
   }
    /**
     * @param int $userID
     *
     * @return bool|mixed
     */
    protected function checkUser(int $userID):bool
    {
      $cache = Cache::for('checked_users', DAY_IN_SECONDS)->connect('user', true);
      return $cache->remember(
         $userID,
         function() use ($userID) {
            return (bool)get_userdata($userID)?:null;
         }
      );
    }
   public function markRead(int $userID, int|array $notification_id):bool
   {
      return $this->notifications->markRead($userID, $notification_id);
   }
   public function markDismissed(int $userID, int|array $notification_id):bool
   {
      return $this->notifications->markDismissed($userID, $notification_id);
   }
   public function markActioned(int $userID, int|array $notification_id, array $result = []):bool
   {
      return $this->notifications->markActioned($userID, $notification_id, $result);
   }
   public function getNotificationTypes(bool $all = false):array
   {
      return $this->notifications->getNotificationTypes($all);
   }
   /************************************************
    * CONTENT NOTIFICATIONS
    * These are pooled notifications of new content for:
    *       - a particular artist
    *       - a particular term
    ************************************************/
   /************************************************
   * EMAIL DIGESTS
   ************************************************/
}