| | |
| | | <?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 |
| | | * |
| | |
| | | */ |
| | | 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 |
| | | ************************************************/ |
| | | } |