[ 'new_favourite', 'list_shared', ], 'artist' => [ 'new_artist', 'new_tattoo', 'new_piercing', 'new_event', 'new_update', ], 'partner' => [ 'new_partner', 'new_offer', ], 'shop' => [ 'new_shop', 'shop_update', 'shop_accepted', 'artist_request', ], 'event' => [ 'new_event', 'event_reminder', ], 'news' => [ 'new_update', ], 'system' => [ 'system_message', 'artist_approved', 'artist_invitation', 'artist_request', 'shop_accepted', 'shop_rejected', 'new_term', 'term_approved', 'term_rejected', ], ]; protected array $notificationTableMap = [ // Regular notifications 'notifications' => [ 'new_favourite', 'artist_approved', 'artist_rejected', 'artist_invitation', 'shop_invitation', 'artist_request', 'shop_accepted', 'shop_rejected', 'new_term', 'term_approved', 'term_rejected', 'list_shared', 'system_message' ], // Content notifications (from artists the user follows) 'content_notifications' => [ 'new_artist', 'new_tattoo', 'new_piercing', 'new_event', 'new_update', 'new_partner', 'new_offer', 'new_shop', 'shop_update', 'event_reminder' ] ]; public function __construct() { $this->cache_name = 'notifications'; parent::__construct(); $allTypes = []; foreach ($this->typeMap as $key => $values) { $allTypes = array_unique(array_merge($allTypes, $values)); } $this->typeMap['all'] = $allTypes; $this->user_id = get_current_user_id(); $this->action = 'notifications-'; add_action('init', [$this, 'init']); add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } /** * Format a notification for display * * @param object $notification Notification object * * @return array Formatted notification */ protected function formatNotification(object $notification):array { $config = $this->notification_types[$notification->type] ?? []; $context = json_decode($notification->context ?? '{}', true); // Get action user's name if available $acting_user_name = null; if ($notification->action_user_id) { $acting_user_name = jvbShareName($notification->action_user_id); } return [ 'id' => $notification->id, 'type' => $notification->type, 'message' => $notification->message, 'created_at' => $notification->created_at, 'status' => $notification->status, 'requires_action' => (bool)$notification->requires_action, 'action_taken' => (bool)$notification->action_taken, 'icon' => $config['icon'] ?? 'info', 'priority' => $notification->priority, 'target' => [ 'id' => $notification->target_id, 'type' => $notification->target_type ], 'context' => $context, 'acting_user' => $notification->action_user_id ? [ 'id' => $notification->action_user_id, 'name' => $acting_user_name ] : null ]; } /** * Set up required paramaters * @return void */ public function init() { $this->manager = JVB()->notification(); $this->notification_types = $this->manager->getNotificationTypes(); } /** * Registers notification routes * @return void */ public function registerRoutes():void { register_rest_route($this->namespace, '/notifications', [ [ 'methods' => 'GET', 'callback' => [$this, 'getNotifications'], 'permission_callback' => [$this, 'checkPermission'] ], [ 'methods' => 'POST', 'callback' => [$this, 'updateNotifications'], 'permission_callback' => [$this, 'checkPermission'] ] ]); } /** * @param int $ID * @param string $content * * @return string */ protected function getItemLink(int $ID, string $content):string { switch ($content) { case BASE.'artist': case BASE.'artwork': case BASE.'event': case BASE.'news': case BASE.'offer': case BASE.'partner': case BASE.'piercing': case BASE.'tattoo': return get_permalink($ID); default: return get_term_link($ID, BASE.$content); } } /** * Get notification actions * * @param string $type Notification type * @param array $data Notification data * @param object $notification Full notification object * * @return array Actions available for this notification */ protected function getNotificationActions(string $type, array $data, object $notification = null):array { error_log('Data for actions: '.print_r($data, true)); $actions = []; switch ($type) { case 'artist_approved': case 'artist_rejected': case 'shop_approved': case 'shop_rejected': case 'term_approved': case 'term_rejected': case 'system_message': //No extra action needed break; case 'artist_invitation': $actions[] = [ 'icon' => 'upvote', 'label' => 'Approve', 'action' => 'acceptInvitation', ]; $actions[] = [ 'icon' => 'downvote', 'label' => 'Reject', 'action' => 'reject_invitation', ]; break; case 'artist_request': $actions[] = [ 'icon' => 'upvote', 'label' => 'Approve', 'action' => 'accept_to_shop', ]; $actions[] = [ 'icon' => 'downvote', 'label' => 'Reject', 'action' => 'reject_to_shop', ]; break; case 'artist_approval': $actions[] = [ 'icon' => 'upvote', 'label' => 'Approve', 'action' => 'accept_artist', ]; $actions[] = [ 'icon' => 'downvote', 'label' => 'Reject', 'action' => 'reject_artist', ]; break; case 'new_term': $actions[] = [ 'icon' => 'upvote', 'label' => 'Approve', 'action' => 'approve_term', ]; $actions[] = [ 'icon' => 'downvote', 'label' => 'Reject', 'action' => 'reject_term', ]; break; case 'list_shared': if (!empty($data['list_id'])) { $actions[] = [ 'icon' => 'list-heart', 'label' => 'View List', 'url' => home_url("/dash/favourites/{$data['list_id']}"), ]; } break; default: $actions[] = [ 'icon' => 'link', 'label' => 'View', 'url' => $this->getItemLink($data['target_id'], $data['target_type']), ]; break; } $actions[] = [ 'icon' => 'close', 'label' => 'Dismiss', 'action' => 'dismiss_notification' ]; // Allow customization via filter return apply_filters('jvb_notification_actions', $actions, $type, $data, $notification); } /** * @param int $user_id * @param array $data * * @return array */ protected function getSanitizedData(int $user_id, array $data):array { $status = (array_key_exists('status', $data)) ? $data['status'] : 'unread'; $limit = (array_key_exists('limit', $data)) ? $data['limit'] : 20; $offset = (array_key_exists('page', $data)) ? $data['page'] : 1; $type = (array_key_exists('type', $data)) ? $data['type'] : 'all'; // Validate and sanitize status $allowed_statuses = ['unread', 'read', 'actioned', 'dismissed', 'all']; if (!in_array($status, $allowed_statuses)) { $this->logError("Invalid notification status", [ 'status' => $status, 'user' => $user_id ], 'warning'); $status = 'unread'; // Default to unread if invalid } if (!in_array($type, array_keys($this->typeMap))) { $this->logError("Invalid notification type", [ 'type' => $type, 'user' => $user_id ], 'warning'); $type = 'all'; } // Validate and sanitize limit and offset $limit = absint($limit); if ($limit <= 0 || $limit > 100) { $limit = 20; // Use reasonable default if invalid } $offset = absint($offset); if ($offset < 0) { $offset = 1; } $return = [ 'status' => $status, 'limit' => $limit, 'page' => $offset, 'type' => $type ]; if (array_key_exists('grouped', $data)) { $return['grouped'] = $data['grouped']; } return $return; } /** * Get notifications for a user * * @param WP_REST_Request $request * @return WP_REST_Response */ public function getNotifications(WP_REST_Request $request): WP_REST_Response { $data = $request->get_params(); $user_id = $data['user']; if (!$this->userCheck($user_id)) { $this->logError("Invalid user ID for notifications", ['user' => $user_id], 'warning'); return new WP_REST_Response([ 'success' => false, 'message' => 'User doesn\'t match. Are you a bot?' ]); } $params = $this->getSanitizedData($user_id, $data); $params['user'] = $user_id; $key = $this->cache->generateKey($params); // Check HTTP cache headers (includes notification types in timestamp check) $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } // Step 1: Build status/order/filter params $status = $params['status']; $limit = $params['limit']; $offset = $params['page']; $type = $params['type']; // Try cache first with validated parameters $cached = $this->cache->get($key); if ($cached) { $response = new WP_REST_Response($cached); return $this->addCacheHeaders($response); } try { // Step 2: Get regular notifications $regular_notifications = $this->getRegularNotifications($user_id, $params); // Step 3: Get content notifications $content_notifications = $this->getContentNotifications($user_id, $status, $limit, $offset); // Step 4: Get approval notifications $approval_notifications = $this->getApprovalNotifications($user_id, $status); // Step 5: Merge in order of created date $notifications = array_merge( $regular_notifications, $content_notifications, $approval_notifications ); usort($notifications, function ($a, $b) { $date_a = strtotime($a['created_at'] ?? $a['date'] ?? date('Y-m-d H:i:s')); $date_b = strtotime($b['created_at'] ?? $b['date'] ?? date('Y-m-d H:i:s')); return $date_b - $date_a; // Sort from newest to oldest }); // Apply pagination $total_count = count($notifications); $notifications = array_slice($notifications, 0, $limit); // Step 6: Return result $response = [ 'notifications' => $notifications, 'pagination' => [ 'total' => $total_count, 'page' => $offset, 'per_page' => $limit, 'pages' => ceil($total_count / $limit) ], 'has_more' => ($offset * $limit + count($notifications)) < $total_count ]; // Cache the result $this->cache->set($key, $response); $response = new WP_REST_Response($response); return $this->addCacheHeaders($response); } catch (Exception $e) { $this->logError("Error retrieving notifications", [ 'user_id' => $user_id, 'error' => $e->getMessage() ]); return new WP_REST_Response([ 'notifications' => [], 'pagination' => [ 'total' => 0, 'page' => $offset, 'per_page' => $limit, 'pages' => 0 ], 'has_more' => false ]); } } /** * Get regular notifications from the notifications table * * @param int $user_id User ID * @param array $params Filter parameters * @return array Array of formatted notifications */ protected function getRegularNotifications(int $user_id, array $params): array { $status = $params['status']; $limit = $params['limit']; $offset = $params['page']; $type = $params['type']; // Try to get from cache first with validated parameters $cache_key = "user_{$user_id}_regular_notifications_{$status}_{$type}_{$limit}_{$offset}"; $cached = $this->cache->get($cache_key); if ($cached) { return $cached; } global $wpdb; $notifications_table = $wpdb->prefix . BASE . 'notifications'; // Build status condition $status_condition = "1=1"; if ($status === 'unread') { $status_condition = "status = 'unread'"; } elseif ($status === 'read') { $status_condition = "status IN ('read', 'actioned')"; } elseif ($status !== 'all') { $status_condition = $wpdb->prepare("status = %s", $status); } // Build type condition $type_condition = "1=1"; if ($type !== 'all' && isset($this->typeMap[$type])) { $types = $this->typeMap[$type]; if (!empty($types)) { $placeholders = implode(',', array_fill(0, count($types), '%s')); $type_condition = $wpdb->prepare("type IN ($placeholders)", $types); } } // Get notifications $notifications = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$notifications_table} WHERE owner_id = %d AND {$status_condition} AND {$type_condition} ORDER BY created_at DESC", $user_id ) ); // Format notifications $formatted = []; foreach ($notifications as $notification) { $formatted[] = $this->formatNotification($notification); } // Cache the results $this->cache->set($cache_key, $formatted, 'notifications_' . $user_id); return $formatted; } /** * Get approval notifications from the approval_requests table * * @param int $user_id User ID * @param string $status Filter by status * @return array Array of formatted approval notifications */ protected function getApprovalNotifications(int $user_id, string $status): array { // Try to get from cache first $cache_key = "user_{$user_id}_approval_notifications_{$status}"; $cached = $this->cache->get($cache_key); if ($cached) { return $cached; } global $wpdb; $formatted = []; // Build status condition $status_condition = "1=1"; if ($status === 'unread') { $status_condition = "a.status = 'pending'"; } elseif ($status === 'read') { $status_condition = "a.status IN ('approved', 'rejected')"; } elseif ($status !== 'all') { $status_condition = $wpdb->prepare("a.status = %s", $status); } if ($this->isVerifiedUser($user_id)) { $approvals = jvbApprovalTypes(); foreach ($approvals as $type => $config) { $table = $wpdb->prefix.BASE.'approval_'.$type.'requests'; $votes = $wpdb->prefix.BASE.'approval_'.$type.'votes'; $approvals = $wpdb->get_results( $wpdb->prepare( "SELECT a.*, COALESCE(v.vote, 'none') as user_vote FROM {$table} a LEFT JOIN {$votes} v ON a.id = v.request_id AND v.user_id = %d WHERE a.user_id != %d AND {$status_condition} ORDER BY a.created_at DESC", $user_id, $user_id ) ); // Now filter out requests created by the current user foreach ($approvals as $approval) { $requested_by = json_decode($approval->requested_by, true); // Skip if the current user is the requester if (is_array($requested_by) && in_array($user_id, $requested_by)) { continue; } $formatted[] = $this->formatApprovalNotification($approval); } } } // Cache the results $this->cache->set($cache_key, $formatted, 'approvals'); return $formatted; } /** * Format an approval request as a notification * * @param object $approval Approval request object * @return array Formatted notification */ protected function formatApprovalNotification(object $approval): array { $data = json_decode($approval->data ?? '{}', true); $type_labels = [ 'artist_approval' => 'Artist Verification', 'term_suggestion' => 'Term Suggestion' ]; $status_labels = [ 'pending' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected', 'expired' => 'Expired' ]; $icon = ($approval->type === 'artist_approval') ? 'artist' : 'style'; $message = ''; if ($approval->type === 'artist_approval') { if ($approval->requested_by == $approval->target_id) { $message = "Your artist verification is {$status_labels[$approval->status]}"; } else { $name = isset($data['display_name']) ? $data['display_name'] : 'An artist'; $message = "{$name} is requesting verification"; } } elseif ($approval->type === 'term_suggestion') { $term_name = $data['term_name'] ?? 'A term'; $taxonomy = $data['taxonomy'] ?? ''; $taxonomy_name = str_replace(BASE, '', $taxonomy); if ($approval->requested_by == get_current_user_id()) { $message = "Your {$taxonomy_name} suggestion '{$term_name}' is {$status_labels[$approval->status]}"; } else { $message = "New {$taxonomy_name} suggestion: '{$term_name}'"; } } return [ 'id' => 'approval_' . $approval->id, 'type' => $approval->type, 'message' => $message, 'created_at' => $approval->created_at, 'status' => $approval->status, 'requires_action' => ($approval->status === 'pending' && $approval->requested_by != get_current_user_id()), 'action_taken' => !empty($approval->user_vote) && $approval->user_vote !== 'none', 'icon' => $icon, 'priority' => 'high', 'target' => [ 'id' => $approval->target_id, 'type' => $approval->target_type ], 'context' => $data, 'approval_data' => [ 'required_approvals' => $approval->required_approvals, 'current_approvals' => $approval->current_approvals, 'expires_at' => $approval->expires_at ] ]; } /** * Determine which table a notification type belongs to * * @param string $notificationType The notification type * @return string The table name ('notifications' or 'content_notifications') */ protected function getTableForNotificationType(string $notificationType): string { foreach ($this->notificationTableMap as $table => $types) { if (in_array($notificationType, $types)) { return $table; } } // Default to the main notifications table if type is unknown return 'notifications'; } /** * Get grouped notifications for a user * @param int $user_id User ID * @param string $status notification status * @param int $limit number of notifications to fetch * @param int $offset page to fetch * @param string $type notification type to fetch * @return array Grouped notifications with pagination info */ public function getGroupedNotifications(int $user_id, string $status, int $limit, int $offset, string $type):array { $cache_key = "user_{$user_id}_grouped_notifications_{$status}_{$type}_{$limit}_{$offset}"; $cached = $this->cache->get($cache_key); if ($cached !== false) { return $cached; } global $wpdb; // Build status condition $status_condition = "1=1"; if ($status === 'unread') { $status_condition = "status = 'unread'"; } elseif ($status === 'read') { $status_condition = "status IN ('read', 'actioned')"; } elseif ($status !== 'all') { $status_condition = $wpdb->prepare("status = %s", $status); } // Build type condition $type_condition = "1=1"; if ($type !== 'all' && isset($this->typeMap[$type])) { $types = $this->typeMap[$type]; if (!empty($types)) { $placeholders = implode(',', array_fill(0, count($types), '%s')); $type_condition = $wpdb->prepare("type IN ($placeholders)", $types); } } try { // Time window for grouping (e.g., last 24 hours) $time_window = '24 HOUR'; $table = $wpdb->prefix.BASE.'notifications'; // Count notifications by action_user_id and type $grouped_counts = $wpdb->get_results( $wpdb->prepare( "SELECT action_user_id, type, COUNT(*) as count, MAX(created_at) as latest_time, MIN(id) as first_id, GROUP_CONCAT(target_type) as target_types FROM {$table} WHERE owner_id = %d AND action_user_id IS NOT NULL AND {$status_condition} AND {$type_condition} AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window}) GROUP BY action_user_id, type ORDER BY latest_time DESC LIMIT %d OFFSET %d", $user_id, $limit, $offset ) ); // Get total count for pagination $total_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT CONCAT(action_user_id, '_', type)) FROM {$table} WHERE owner_id = %d AND action_user_id IS NOT NULL AND {$status_condition} AND {$type_condition} AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window})", $user_id ) ); // Format the grouped notifications $formatted = []; foreach ($grouped_counts as $group) { // Get acting user name $acting_user_name = jvbGetUsername($group->action_user_id); if ($group->count > 1) { // Get unique target types for better message formatting $target_types = array_unique(explode(',', $group->target_types)); // Build a grouped notification $message = $this->buildGroupedMessage( $acting_user_name, $group->type, $group->count, $target_types ); $formatted[] = [ 'id' => 'group_' . $group->first_id, 'type' => $group->type, 'message' => $message, 'created_at' => $group->latest_time, 'timestamp' => strtotime($group->latest_time), 'status' => $status, 'icon' => $this->notification_types[$group->type]['icon'] ?? 'info', 'priority' => $this->notification_types[$group->type]['priority'] ?? 'normal', 'is_grouped' => true, 'group_count' => $group->count, 'acting_user' => [ 'id' => $group->action_user_id, 'name' => $acting_user_name ], 'target_types' => $target_types ]; } else { // Get the single notification details $table = $wpdb->prefix.BASE.'notifications'; $notification = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE owner_id = %d AND action_user_id = %d AND type = %s ORDER BY created_at DESC LIMIT 1", $user_id, $group->action_user_id, $group->type ) ); if ($notification) { $formatted[] = $this->formatNotification($notification); } } } // Prepare response with pagination info $response = [ 'notifications' => $formatted, 'pagination' => [ 'total' => (int)$total_count, 'page' => $offset, 'per_page' => $limit, 'pages' => ceil($total_count / $limit) ], 'has_more' => ($offset + $limit) < $total_count ]; // Cache the results $this->cache->set($cache_key, $response, 'notifications_' . $user_id); return $response; } catch (Exception $e) { $this->logError("Error retrieving grouped notifications", [ 'user_id' => $user_id, 'status' => $status, 'error' => $e->getMessage() ]); return [ 'notifications' => [], 'pagination' => [ 'total' => 0, 'page' => $offset, 'per_page' => $limit, 'pages' => 0 ], 'has_more' => false ]; } } /** * Build a message for grouped notifications * * @param string $user_name Acting user's name * @param string $type Notification type * @param int $count Number of grouped notifications * @param array $target_types Types of targets involved * @return string Formatted message */ protected function buildGroupedMessage(string $user_name, string $type, int $count, array $target_types = []):string { switch ($type) { case 'new_favourite': // If we have a single target type if (count($target_types) === 1) { $content_type = $this->manager->getContentTypeLabel($target_types[0]); return "{$user_name} favourited {$count} of your {$content_type}"; } return "{$user_name} favourited {$count} of your items"; case 'artist_request': return "{$user_name} wants to join your shop"; // Add more cases for other notification types default: return "{$user_name} has {$count} notifications for you"; } } /** * Build Notification request * * @param WP_REST_Request $request * @return array Sanitized parameters for checking the cache */ protected function buildParams(WP_REST_Request $request):array { $request = $request->get_params(); return [ 'status' => (array_key_exists('status', $request) && in_array($request['status'], ['all', 'unread', 'expired'])) ? $request['status'] : 'unread', 'user_id' => (array_key_exists('user', $request) && is_int($request['user'])) ? $request['user'] : get_current_user_id(), 'page' => (array_key_exists('page', $request) && is_numeric($request['page'])) ? $request['page'] : 1, 'type' => (array_key_exists('type', $request) && in_array($request['type'], array_keys($this->manager->notification_types))) ? $request['type'] : 'all', ]; } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function updateNotifications(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); $action = $request->get_param('action'); $notificationID = (array_key_exists('notification', $data) && is_int($data['notification'])) ? $data['notification'] : false; $args = $this->buildParams($request); $data = []; $error = ''; switch ($action) { case 'mark_as_read': if (is_null($notificationID)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Notification ID is required' ]); } $data = [ 'user_id' => $args['user_id'], 'notification_id' => $notificationID, ]; break; case 'mark_all_as_read': $data = [ 'user_id' => $args['user_id'], ]; if ($request->get_param('notification_ids')) { //To bulk mark all ids $data['notification_ids'] = array_map('intval', $request->get_param('notification_ids')); } if ($request->get_param('type')) { //To bulk select all items by type $data['type'] = $request->get_param('type'); } break; case 'approve_artist': case 'reject_artist': //TODO: hook into the approval routes already set up $handler = JVB()->routes('approvals'); $ID = $handler->getVerificationDetails($args['user_id']); $notes = isset($request['notes']) ? sanitize_text_field($request['notes']) : ''; $handler->voteForArtist($args['user_id'], $ID['request'], $action==='approve_artist', $notes); $this->markActioned($notificationID, $args['user_id']); break; case 'acceptInvitation': case 'reject_invitation': //TODO: hook into the shop routes already set up $handler = JVB()->routes('shop'); $notification_data = $this->getNotification($notificationID); if ($action === 'acceptInvitation') { $result = $handler->acceptShopInvitation($args['user_id'], $args['shop_id']); } else { $result = $handler->declineShopInvitation($args['user_id'], $args['shop_id']); } $this->markActioned($notificationID, $args['user_id']); break; case 'accept_to_shop': case 'reject_to_shop': $handler = JVB()->routes('shop'); if ($action === 'accept_to_shop') { $result = $handler->addArtistToShop($args['user_id'], $args['shop_id']); } else { //TODO: notify requester that their request has been denied } $this->markActioned($notificationID, $args['user_id']); break; case 'approve_term': case 'reject_term': $handler = JVB()->routes('approvals'); $notification_data = $this->getNotification($notificationID); $notes = isset($request['notes']) ? sanitize_text_field($request['notes']) : ''; if ($action === 'approve_term') { $result = $handler->approveTerm($args['user_id'], $notification_data, $notes); } else { $result = $handler->rejectTerm($args['user_id'], $notification_data, $notes); } $this->markActioned($notificationID, $args['user_id']); break; case 'dismiss_notification': $data = [ 'user_id' => $args['user_id'], 'notification_id' => $notificationID, ]; break; default: $error = 'Invalid action'; } if (!empty($data)) { JVB()->queue()->queueOperation( 'notification_'.$action, $args['user_id'], $data ); return new WP_REST_Response([ 'success' => true, 'message' => __('Notification queued for processing', 'jvb') ]); } else { return new WP_REST_Response([ 'success' => false, 'message' => __('Error: '.$error, 'jvb') ]); } } /** * Get notification data by ID * @param int $notification_id Notification ID * @return array|false Notification data or false if not found */ protected function getNotification(int $notification_id):array|false { global $wpdb; $table = $wpdb->prefix.BASE.'notifications'; $notification = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $notification_id ), ARRAY_A ); if (!$notification) { return false; } // Parse context data $context = !empty($notification['context']) ? json_decode($notification['context'], true) : []; return array_merge($notification, $context); } /** * @param WP_Error|array $result * @param object $operation * @param array $data * * @return int|mixed */ public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array { switch ($operation->type) { case 'notification_mark_as_read': $result = $this->markRead($data['notification_id'], $operation->user_id); break; case 'notification_mark_all_as_read': $result = $this->markAllRead($data); break; case 'notification_dismiss_notification': $result = $this->markDismissed($data['notification_id'], $operation->user_id); break; } return $result; } /** * Mark all notifications as read for a user * * @param array $data Data containing user_id and optional filters * * @return array Number of notifications marked as read */ public function markAllRead(array $data):array { $user_id = $data['user_id']; $type = $data['type'] ?? null; $notification_ids = $data['notification_ids'] ?? null; global $wpdb; $table = $wpdb->prefix . BASE . 'notifications'; $where = [ 'owner_id = %d', "status = 'unread'" ]; $params = [$user_id]; // Add type filter if specified if ($type) { $where[] = "type = %s"; $params[] = $type; } // Add specific IDs filter if provided if ($notification_ids && is_array($notification_ids)) { $id_placeholders = implode(',', array_fill(0, count($notification_ids), '%d')); $where[] = "id IN ($id_placeholders)"; $params = array_merge($params, $notification_ids); } $where_clause = implode(' AND ', $where); // Update all matching notifications $updated = $wpdb->query($wpdb->prepare( "UPDATE $table SET status = 'read', read_at = %s, updated_at = %s WHERE $where_clause", array_merge( [current_time('mysql'), current_time('mysql')], $params ) )); if ($updated) { $this->trackNotificationMetrics(0, $user_id, 'batch_read', ['count' => $updated]); $this->clearNotificationCache($user_id); } return [ 'success' => true, 'result' => $updated ]; } /** * Mark a notification as read * * @param int $notification_id Notification ID * @param int $user_id User ID making the request (for security) * * @return array Success or failure */ public function markRead(int $notification_id, int $user_id):array { if (!$this->checkUser($user_id)) { return [ 'success' => false, 'result' => 'Invalid user' ]; } global $wpdb; //TODO: We need to set up a system to check the main notification table, but also the content notification table // Verify ownership $table = $wpdb->prefix . BASE . 'notifications'; $notification = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND owner_id = %d", $notification_id, $user_id ) ); if (!$notification) { return [ 'success' => false, 'result' => 'Invalid notification' ]; } // Mark as read $result = $wpdb->update( $wpdb->prefix . BASE . 'notifications', [ 'status' => 'read', 'read_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['id' => $notification_id] ); if ($result) { $this->clearNotificationCache($user_id); } return [ 'success' => $result !== false, 'result' => $result ]; } /** * Mark a notification as actioned * * @param int $notification_id Notification ID * @param int $user_id User ID making the request * * @return array */ public function markActioned(int $notification_id, int $user_id):array { if (!$this->checkUser($user_id)) { return [ 'success' => false, 'message' => 'Invalid user' ]; } global $wpdb; //TODO: We need to set up a system to check the main notification table, but also the content notification table // Start a transaction $wpdb->query('START TRANSACTION'); try { $table = $wpdb->prefix.BASE.'notifications'; // Verify ownership and that notification requires action $notification = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND user_id = %d AND requires_action = 1 FOR UPDATE", // Lock the row $notification_id, $user_id ) ); if (!$notification) { $wpdb->query('ROLLBACK'); $this->logError("Failed to action notification - not found or not owned", [ 'notification_id' => $notification_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'No notification found, or invalid owner' ]; } // Mark as actioned $result = $wpdb->update( $wpdb->prefix . BASE . 'notifications', [ 'status' => 'actioned', 'action_taken' => 1, 'actioned_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['id' => $notification_id] ); if ($result !== false) { $wpdb->query('COMMIT'); $this->clearNotificationCache($user_id); return [ 'success' => true, 'message' => 'Notification actioned' ]; } else { $wpdb->query('ROLLBACK'); $this->logError("Database error marking notification as actioned", [ 'notification_id' => $notification_id, 'user_id' => $user_id, 'db_error' => $wpdb->last_error ]); return [ 'success' => false, 'message' => 'Error' ]; } } catch (Exception $e) { $wpdb->query('ROLLBACK'); $this->logError("Exception marking notification as actioned", [ 'notification_id' => $notification_id, 'user_id' => $user_id, 'error' => $e->getMessage() ]); return [ 'success' => false, 'message' => $e->getMessage(), ]; } } /** * Dismiss a notification * * @param int $notification_id Notification ID * @param int $user_id User ID making the request * * @return array Success or failure */ public function markDismissed(int $notification_id, int $user_id):array{ if (!$this->checkUser($user_id)) { return [ 'success' => false, 'result' => 'Invalid User', ]; } global $wpdb; // Verify ownership $table = $wpdb->prefix.BASE.'notifications'; $notification = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND user_id = %d", $notification_id, $user_id ) ); //TODO: We need to set up a system to check the main notification table, but also the content notification table if (!$notification) { return [ 'success' => false, 'result' => 'Invalid notification' ]; } // Mark as dismissed $result = $wpdb->update( $wpdb->prefix . BASE . 'notifications', [ 'status' => 'dismissed', 'updated_at' => current_time('mysql') ], [ 'id' => $notification_id ] ); if ($result) { $this->clearNotificationCache($user_id); } return [ 'success' => $result !== false, 'result' => $result ]; } /*** * Content notifications (in a separate database)) */ /** * Get content notifications for a user * * @param int $user_id User ID * @param string $status Status filter (unread, read, all) * @param int $limit Maximum number of notifications to return * @param int $offset Pagination offset * * @return array Content notifications */ public function getContentNotifications( int $user_id, string $status = 'unread', int $limit = 20, int $offset = 0 ):array { if (!$this->checkUser($user_id)) { return []; } // Try cache first $cache_key = "user_{$user_id}_content_notifications_{$status}_{$limit}_{$offset}"; $cached = $this->cache->get($cache_key); if ($cached !== false) { return $cached; } global $wpdb; // Build status condition $status_condition = "1=1"; if ($status === 'unread') { $status_condition = "seen.status = 'unread'"; } elseif ($status === 'read') { $status_condition = "seen.status = 'read'"; } elseif ($status !== 'all') { $status_condition = $wpdb->prepare("seen.status = %s", $status); } // Get content notifications this user has seen records for $notifications = $wpdb->get_results( $wpdb->prepare( "SELECT seen.*, content.* FROM {$wpdb->prefix}{$this->base}notifications_user_seen AS seen JOIN {$wpdb->prefix}{$this->base}notifications_content AS content ON seen.content_notification_id = content.id WHERE seen.user_id = %d AND {$status_condition} ORDER BY content.date DESC, content.created_at DESC LIMIT %d OFFSET %d", $user_id, $limit, $offset ) ); // Format content notifications $formatted = []; foreach ($notifications as $notification) { $formatted[] = $this->formatContentNotification($notification); } // Cache the results $this->cache->set($cache_key, $formatted, 'notifications_' . $user_id); return $formatted; } /** * Format a content notification for display * * @param object $notification Content notification object * * @return array Formatted content notification */ protected function formatContentNotification(object $notification):array { // Get artist data $artist_data = jvbContentFromUser($notification->user_id); // Parse JSON data $new_items = json_decode($notification->new_items, true) ?: []; $updated_items = json_decode($notification->updated_items, true) ?: []; // Count items by type $counts_by_type = []; $total_new = 0; foreach ($new_items as $type => $ids) { $clean_type = str_replace(BASE, '', $type); $counts_by_type[ $clean_type ] = [ 'new' => count($ids), 'updated' => 0 ]; $total_new += count($ids); } foreach ($updated_items as $type => $ids) { $clean_type = str_replace(BASE, '', $type); if (!isset($counts_by_type[ $clean_type ])) { $counts_by_type[ $clean_type ] = [ 'new' => 0 ]; } $counts_by_type[ $clean_type ]['updated'] = count($ids); } // Build summary text $summary = []; foreach ($counts_by_type as $type => $counts) { if ($counts['new'] > 0) { $label = $counts['new'] === 1 ? $type : $this->pluralize($type); $summary[] = "{$counts['new']} new {$label}"; } if ($counts['updated'] > 0) { $label = $counts['updated'] === 1 ? $type : $this->pluralize($type); $summary[] = "{$counts['updated']} updated {$label}"; } } return [ 'id' => $notification->id, 'seen_id' => $notification->content_notification_id, 'status' => $notification->status, 'date' => $notification->date, 'artist' => $artist_data, 'new_items' => $new_items, 'updated_items' => $updated_items, 'summary' => implode(', ', $summary), 'total_new' => $total_new, 'total_updated' => $notification->total_items - $total_new, 'counts_by_type' => $counts_by_type, 'has_profile_update' => (bool) $notification->has_profile_update, 'created_at' => $notification->created_at ]; } /** * Mark a content notification as read * * @param int $seen_id User seen record ID * @param int $user_id User ID making the request * * @return array Success or failure */ public function markContentNotificationRead(int $seen_id, int $user_id):array { // Validate input parameters if (!$this->checkUser($seen_id)) { $this->logError("Invalid seen ID", [ 'seen_id' => $seen_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'Invalid User ID' ]; } if (!$this->checkUser($user_id)) { $this->logError("Invalid user ID", [ 'seen_id' => $seen_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'Invalid User ID' ]; } global $wpdb; $table = $wpdb->prefix . BASE . 'notifications_user_seen'; // Start transaction $wpdb->query('START TRANSACTION'); try { // Verify ownership with row locking $seen_record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND user_id = %d FOR UPDATE", $seen_id, $user_id ) ); if (!$seen_record) { $wpdb->query('ROLLBACK'); $this->logError("Seen record not found or not owned by user", [ 'seen_id' => $seen_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'Seen record not found or not owned by user' ]; } // Mark as read $result = $wpdb->update( $table, [ 'status' => 'read', 'read_at' => current_time('mysql') ], ['id' => $seen_id] ); if ($result !== false) { $wpdb->query('COMMIT'); $this->clearNotificationCache($user_id); return [ 'success' => true, 'message' => 'Successfully marked as seen' ]; } else { $wpdb->query('ROLLBACK'); $this->logError("Database error marking content notification as read", [ 'seen_id' => $seen_id, 'user_id' => $user_id, 'db_error' => $wpdb->last_error ]); return [ 'success' => false, 'message' => 'Something went wrong...' ]; } } catch (Exception $e) { $wpdb->query('ROLLBACK'); $this->logError("Exception marking content notification as read", [ 'seen_id' => $seen_id, 'user_id' => $user_id, 'error' => $e->getMessage() ]); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Dismiss a content notification * * @param int $seen_id User seen record ID * @param int $user_id User ID making the request * * @return array Success or failure */ public function dismissContentNotification( int $seen_id, int $user_id ):array { // Validate input parameters if (!$this->checkUser($seen_id)) { $this->logError("Invalid seen ID", [ 'seen_id' => $seen_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'Invalid User ID' ]; } if (!$this->checkUser($user_id)) { $this->logError("Invalid user ID", [ 'seen_id' => $seen_id, 'user_id' => $user_id ], 'warning'); return [ 'success' => false, 'message' => 'Invalid User ID' ]; } global $wpdb; // Verify ownership $seen_record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}{$this->base}notifications_user_seen WHERE id = %d AND user_id = %d", $seen_id, $user_id ) ); if (!$seen_record) { return [ 'success' => false, 'message' => 'No notification found' ]; } // Mark as dismissed $result = $wpdb->update( $wpdb->prefix . BASE . 'notifications_user_seen', [ 'status' => 'dismissed' ], [ 'id' => $seen_id ] ); if ($result) { $this->clearNotificationCache($user_id); } return [ 'success' => $result !== false, 'message' => 'Operation completed', ]; } /** * Simple pluralization helper * * @param string $word Word to pluralize * @return string Pluralized word */ protected function pluralize(string $word): string { $irregular = [ 'tattoo' => 'tattoos', 'piercing' => 'piercings', 'artwork' => 'artwork', 'news' => 'news', 'offer' => 'offers', 'event' => 'events' ]; if (isset($irregular[$word])) { return $irregular[$word]; } // Simple pluralization rules if (substr($word, -1) === 'y') { return substr($word, 0, -1) . 'ies'; } return $word . 's'; } /** * Track notification metrics for analytics * * @param int $notification_id Notification ID (0 for batch operations) * @param int $user_id User ID * @param string $action Action taken (read, dismiss, etc.) * @param array $details Additional details * @return bool Success or failure */ protected function trackNotificationMetrics(int $notification_id, int $user_id, string $action, array $details = []): bool { global $wpdb; $metrics_table = $wpdb->prefix . BASE . 'notification_metrics'; try { return $wpdb->insert( $metrics_table, [ 'notification_id' => $notification_id, 'user_id' => $user_id, 'action' => $action, 'action_source' => 'web', 'action_details' => json_encode($details), 'created_at' => current_time('mysql') ] ) !== false; } catch (Exception $e) { $this->logError("Failed to track notification metrics", [ 'user_id' => $user_id, 'action' => $action, 'error' => $e->getMessage() ]); return false; } } /** * Clear notification cache for a user, for all notification types * * @param int $user_id User ID * @return void */ protected function clearNotificationCache(int $user_id): void { // Clear regular notifications cache $this->cache->invalidate("user_{$user_id}_notifications_"); $this->cache->invalidate("user_{$user_id}_regular_notifications_"); // Clear content notifications cache $this->cache->invalidate("user_{$user_id}_content_notifications_"); // Clear approval notifications cache $this->cache->invalidate("user_{$user_id}_approval_notifications_"); // Clear merged notifications cache $this->cache->invalidate("user_{$user_id}_merged_notifications_"); } }