[ '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 ] ]; /** * 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'); // 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(array_keys(JVB_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 = jvbBasedFeedContent(); 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( '', 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 { $content = sprintf('

Hey %s,

', $user->first_name ?: $user->display_name); // Intro text based on frequency switch ($frequency) { case 'daily': $content .= '

Here\'s what happened in Edmonton\'s tattoo scene today:

'; break; case 'weekly': $content .= '

Here\'s what you missed in Edmonton\'s tattoo scene this week:

'; break; case 'monthly': $content .= sprintf('

Here\'s your monthly roundup of what happened in %s in Edmonton\'s tattoo scene:

', 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( '

You\'re receiving this %s digest because you follow artists on edmonton.ink. You can %s at any time.

', $frequency, 'adjust your notification settings' ); return $content; } /** * 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( '

%s

', 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('

%s

', esc_html($type_label)); // Display up to 3 items in a row $display_ids = array_slice($ids, 0, 3); $content .= ''; 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 .= ''; } // Fill empty cells if needed for ($i = count($display_ids); $i < 3; $i++) { $content .= ''; } $content .= '
'; $content .= '
'; $content .= sprintf( '%s', 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 .= '
'; $content .= sprintf('

%s

', esc_html($item_data['title'])); $content .= '
'; // Add "See all" link if there are more items if (count($ids) > 3) { $content .= sprintf( '

See all %d %s →

', 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 .= '
'; } return $content; } /** * Generate HTML section for regular notifications * * @param array $notifications Notification objects * * @return string HTML content */ protected function generateNotificationsSection(array $notifications):string { 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'); } /** * 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 { if (array_key_exists($content, JVB_CONTENT)) { return JVB_CONTENT[$content]['plural']; } elseif (array_key_exists($content, JVB_TAXONOMY)) { return JVB_TAXONOMY[$content]['plural']; } elseif (array_key_exists($content, JVB_USER)) { return JVB_USER[$content]['plural']; } return $content; } /** * @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 { $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' ]); } ); } protected function checkRoles(string|array $roles):array { if (!is_array($roles)) { $roles = explode(',',$roles); } return array_map(function ($r) { return jvbCheckBase(trim($r)); }, array_filter($roles, function ($r) { return array_key_exists(trim($r), JVB_USER); })); } /** * @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; } ); } }