[ '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 = [ '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' => [ 'new_artist', 'new_tattoo', 'new_piercing', 'new_event', 'new_update', 'new_partner', 'new_offer', 'new_shop', 'shop_update', 'event_reminder' ] ]; protected CustomTable $notifications; protected CustomTable $metrics; public function __construct() { $this->cacheName = 'notifications'; $this->cacheTtl = HOUR_IN_SECONDS; parent::__construct(); // Connect notifications cache to user cache // When user data changes, notification cache should invalidate $this->cache->connect('user'); $this->notifications = CustomTable::for('notifications'); $this->metrics = CustomTable::for('notification_metrics'); // Build complete type map $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(); add_action('init', [$this, 'init']); add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } /** * Set up required parameters */ public function init(): void { $this->manager = JVB()->notification(); $this->notification_types = $this->manager->getNotificationTypes(true); } /** * Register notification routes */ public function registerRoutes(): void { // Get user notifications Route::for('notifications') ->get([$this, 'getNotifications']) ->args([ 'user' => 'integer|required', 'type' => 'string', 'status' => 'string|enum:unread,read,actioned,dismissed', 'limit' => 'integer|default:20|min:1|max:100', 'offset' => 'integer|default:0', ]) ->auth('user') ->rateLimit(30) ->register(); // Mark as read Route::for('notifications/read') ->post([$this, 'markRead']) ->args([ 'user' => 'integer|required', 'notification_id' => 'integer|required', ]) ->auth('user') ->rateLimit(30) ->register(); // Mark all as read Route::for('notifications/read-all') ->post([$this, 'markAllRead']) ->args([ 'user' => 'integer|required', 'type' => 'string', ]) ->auth('user') ->rateLimit(10) ->register(); // Mark as actioned Route::for('notifications/action') ->post([$this, 'markActioned']) ->args([ 'user' => 'integer|required', 'notification_id' => 'integer|required', ]) ->auth('user') ->rateLimit(30) ->register(); // Dismiss notification Route::for('notifications/dismiss') ->post([$this, 'markDismissed']) ->args([ 'user' => 'integer|required', 'notification_id' => 'integer|required', ]) ->auth('user') ->rateLimit(30) ->register(); // Get unread count Route::for('notifications/count') ->get([$this, 'getUnreadCount']) ->args([ 'user' => 'integer|required', 'type' => 'string', ]) ->auth('user') ->rateLimit() ->register(); } // ========================================================================= // GET OPERATIONS // ========================================================================= /** * Get notifications for a user */ public function getNotifications(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $type = sanitize_text_field($request->get_param('type') ?? ''); $status = sanitize_text_field($request->get_param('status') ?? ''); $limit = absint($request->get_param('limit')); $offset = absint($request->get_param('offset')); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } $cacheKey = compact('user_id', 'type', 'status', 'limit', 'offset'); $result = $this->cache->remember($cacheKey, function() use ($user_id, $type, $status, $limit, $offset) { // Build where conditions $where = ['owner_id' => $user_id]; if ($type) $where['type'] = $type; if ($status) $where['status'] = $status; $items = $this->notifications ->where($where) ->orderBy('created_at', 'DESC') ->limit($limit, $offset) ->getResults(); $total = $this->notifications->where($where)->countResults(); // Format notifications $formatted = array_map([$this, 'formatNotification'], $items); return [ 'items' => $formatted, 'total' => $total, 'has_more' => ($offset + $limit) < $total ]; }); return $this->success($result); } /** * Get unread notification count */ public function getUnreadCount(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $type = sanitize_text_field($request->get_param('type') ?? ''); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } $cacheKey = compact('user_id', 'type'); $count = $this->cache->remember($cacheKey, function() use ($user_id, $type) { $where = ['owner_id' => $user_id, 'status' => 'unread']; if ($type) $where['type'] = $type; return $this->notifications->where($where)->countResults(); }); return $this->success(['count' => $count]); } /** * Get grouped notifications for a user */ public function getGroupedNotifications( int $user_id, string $status, int $limit, int $offset, string $type ): array { $cacheKey = compact('user_id', 'status', 'limit', 'offset', 'type'); return $this->cache->remember($cacheKey, function() use ($user_id, $status, $limit, $offset, $type) { $time_window = '24 HOUR'; // Build type filter $typeFilter = ''; $typeValues = []; if ($type !== 'all' && isset($this->typeMap[$type])) { $types = $this->typeMap[$type]; if (!empty($types)) { $placeholders = implode(',', array_fill(0, count($types), '%s')); $typeFilter = "AND type IN ({$placeholders})"; $typeValues = $types; } } // Build status filter $statusFilter = ''; if ($status === 'read') { $statusFilter = "AND status IN ('read', 'actioned')"; } elseif ($status !== 'all') { $statusFilter = "AND status = %s"; $typeValues[] = $status; } // Get grouped notifications $grouped = $this->notifications->queryResults( "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 created_at > DATE_SUB(NOW(), INTERVAL {$time_window}) {$statusFilter} {$typeFilter} GROUP BY action_user_id, type ORDER BY latest_time DESC LIMIT %d OFFSET %d", array_merge([$user_id], $typeValues, [$limit, $offset]) ); // Get total count $total = $this->notifications->queryVar( "SELECT COUNT(DISTINCT CONCAT(action_user_id, '_', type)) FROM {table} WHERE owner_id = %d AND action_user_id IS NOT NULL AND created_at > DATE_SUB(NOW(), INTERVAL {$time_window}) {$statusFilter} {$typeFilter}", array_merge([$user_id], $typeValues) ); // Format results $formatted = []; foreach ($grouped as $group) { if ($group->count > 1) { // Grouped notification $target_types = explode(',', $group->target_types); $formatted[] = [ 'id' => 'group_' . $group->first_id, 'type' => $group->type, 'message' => $this->buildGroupedMessage( jvbGetUsername($group->action_user_id), $group->type, $group->count, $target_types ), 'created_at' => $group->latest_time, 'is_grouped' => true, 'group_count' => $group->count, ]; } else { // Single notification $notification = $this->notifications ->where([ 'owner_id' => $user_id, 'action_user_id' => $group->action_user_id, 'type' => $group->type ]) ->orderBy('created_at', 'DESC') ->first(); if ($notification) { $formatted[] = $this->formatNotification($notification); } } } return [ 'notifications' => $formatted, 'pagination' => [ 'total' => (int)$total, 'page' => $offset, 'per_page' => $limit, 'pages' => ceil($total / $limit) ], 'has_more' => ($offset + $limit) < $total ]; }); } /** * Get regular notifications from the notifications table */ protected function getRegularNotifications(int $user_id, array $params): array { $cacheKey = compact('user_id', 'params'); return $this->cache->remember($cacheKey, function() use ($user_id, $params) { $status = $params['status']; $type = $params['type']; $limit = $params['limit']; $offset = $params['page']; // Build base query $where = ['owner_id' => $user_id]; // Handle status filter if ($status === 'read') { // For multiple statuses, use raw query $notifications = $this->getNotificationsWithMultipleStatuses( $user_id, ['read', 'actioned'], $type, $limit, $offset ); } else { // Single status - use fluent builder if ($status !== 'all') { $where['status'] = $status; } // Handle type filter if ($type !== 'all' && isset($this->typeMap[$type])) { $types = $this->typeMap[$type]; if (!empty($types)) { // Multiple types - use raw query return $this->getNotificationsByTypes( $user_id, $types, $status, $limit, $offset ); } } // Simple query - use fluent builder $notifications = $this->notifications ->where($where) ->orderBy('created_at', 'DESC') ->limit($limit, $offset) ->getResults(); } // Format notifications return array_map([$this, 'formatNotification'], $notifications); }); } /** * Get notifications with multiple status values */ protected function getNotificationsWithMultipleStatuses( int $user_id, array $statuses, string $type, int $limit, int $offset ): array { $placeholders = implode(',', array_fill(0, count($statuses), '%s')); $params = array_merge([$user_id], $statuses); $typeCondition = ''; if ($type !== 'all' && isset($this->typeMap[$type])) { $types = $this->typeMap[$type]; if (!empty($types)) { $typePlaceholders = implode(',', array_fill(0, count($types), '%s')); $typeCondition = "AND type IN ({$typePlaceholders})"; $params = array_merge($params, $types); } } $params[] = $limit; $params[] = $offset; return $this->notifications->queryResults( "SELECT * FROM {table} WHERE owner_id = %d AND status IN ({$placeholders}) {$typeCondition} ORDER BY created_at DESC LIMIT %d OFFSET %d", $params ); } /** * Get notifications by multiple types */ protected function getNotificationsByTypes( int $user_id, array $types, string $status, int $limit, int $offset ): array { $placeholders = implode(',', array_fill(0, count($types), '%s')); $params = array_merge([$user_id], $types, [$limit, $offset]); $statusCondition = ''; if ($status !== 'all') { $statusCondition = "AND status = %s"; array_splice($params, -2, 0, [$status]); } return $this->notifications->queryResults( "SELECT * FROM {table} WHERE owner_id = %d AND type IN ({$placeholders}) {$statusCondition} ORDER BY created_at DESC LIMIT %d OFFSET %d", $params ); } /** * Get approval notifications from the approval_requests table */ protected function getApprovalNotifications(int $user_id, string $status): array { $cacheKey = compact('user_id', 'status'); return $this->cache->remember($cacheKey, function() use ($user_id, $status) { if (!$this->isVerifiedUser($user_id)) { return []; } global $wpdb; $formatted = []; // Build status condition $statusCondition = "1=1"; if ($status === 'unread') { $statusCondition = "a.status = 'pending'"; } elseif ($status === 'read') { $statusCondition = "a.status IN ('approved', 'rejected')"; } elseif ($status !== 'all') { $statusCondition = $wpdb->prepare("a.status = %s", $status); } $approvals = Registrar::getFeatured('approve_new'); foreach ($approvals as $type => $config) { $table = $wpdb->prefix . BASE . 'approval_' . $type . 'requests'; $votes = $wpdb->prefix . BASE . 'approval_' . $type . 'votes'; $approvalRequests = $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 {$statusCondition} ORDER BY a.created_at DESC", $user_id, $user_id ) ); // Filter out requests created by current user foreach ($approvalRequests as $approval) { $requested_by = json_decode($approval->requested_by, true); if (is_array($requested_by) && in_array($user_id, $requested_by)) { continue; } $formatted[] = $this->formatApprovalNotification($approval); } } return $formatted; }); } /** * Get content notifications for a user */ public function getContentNotifications( int $user_id, string $status = 'unread', int $limit = 20, int $offset = 0 ): array { if (!$this->checkUser($user_id)) { return []; } $cacheKey = compact('user_id', 'status', 'limit', 'offset'); return $this->cache->remember($cacheKey, function() use ($user_id, $status, $limit, $offset) { global $wpdb; // Build status condition $statusCondition = "1=1"; if ($status === 'unread') { $statusCondition = "seen.status = 'unread'"; } elseif ($status === 'read') { $statusCondition = "seen.status = 'read'"; } elseif ($status !== 'all') { $statusCondition = $wpdb->prepare("seen.status = %s", $status); } $notifications = $wpdb->get_results( $wpdb->prepare( "SELECT seen.*, content.* FROM {$wpdb->prefix}" . BASE . "notifications_user_seen AS seen JOIN {$wpdb->prefix}" . BASE . "notifications_content AS content ON seen.content_notification_id = content.id WHERE seen.user_id = %d AND {$statusCondition} ORDER BY content.date DESC, content.created_at DESC LIMIT %d OFFSET %d", $user_id, $limit, $offset ) ); // Format content notifications return array_map([$this, 'formatContentNotification'], $notifications); }); } // ========================================================================= // UPDATE OPERATIONS // ========================================================================= /** * Mark notification as read */ public function markRead(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $notification_id = absint($request->get_param('notification_id')); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } try { $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) { // Verify ownership $notification = $table ->where(['id' => $notification_id, 'owner_id' => $user_id]) ->first(); if (!$notification) { throw new Exception('Invalid notification'); } $updated = $table->update( [ 'status' => 'read', 'read_at' => current_time('mysql') ], ['id' => $notification_id] ); if (!$updated) { throw new Exception('Failed to update notification'); } return $updated; }); $this->trackMetrics($notification_id, $user_id, 'read'); $this->clearUserCache($user_id); return $this->success(['updated' => $result]); } catch (Exception $e) { return $this->error($e->getMessage()); } } /** * Mark all notifications as read */ public function markAllRead(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $type = sanitize_text_field($request->get_param('type') ?? ''); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } try { $where = ['owner_id' => $user_id, 'status' => 'unread']; if ($type) $where['type'] = $type; $updated = $this->notifications ->where($where) ->updateResults([ 'status' => 'read', 'read_at' => current_time('mysql') ]); if ($updated) { $this->trackMetrics(0, $user_id, 'batch_read', ['count' => $updated]); $this->clearUserCache($user_id); } return $this->success(['updated' => $updated]); } catch (Exception $e) { return $this->error($e->getMessage()); } } /** * Mark notification as actioned */ public function markActioned(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $notification_id = absint($request->get_param('notification_id')); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } try { $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) { // Verify ownership and requires action $notification = $table ->where(['id' => $notification_id, 'owner_id' => $user_id, 'requires_action' => 1]) ->first(); if (!$notification) { throw new Exception('Invalid notification or does not require action'); } $updated = $table->update( [ 'status' => 'actioned', 'action_taken' => 1, 'actioned_at' => current_time('mysql') ], ['id' => $notification_id] ); if (!$updated) { throw new Exception('Failed to update notification'); } return $updated; }); $this->trackMetrics($notification_id, $user_id, 'actioned'); $this->clearUserCache($user_id); return $this->success(['message' => 'Notification actioned']); } catch (Exception $e) { return $this->error($e->getMessage()); } } /** * Dismiss notification */ public function markDismissed(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $notification_id = absint($request->get_param('notification_id')); if (!$this->checkUser($user_id)) { return $this->unauthorized(); } try { $result = $this->notifications->transaction(function($table) use ($notification_id, $user_id) { // Verify ownership $notification = $table ->where(['id' => $notification_id, 'owner_id' => $user_id]) ->first(); if (!$notification) { throw new Exception('Invalid notification'); } $updated = $table->update( [ 'status' => 'dismissed', 'dismissed_at' => current_time('mysql') ], ['id' => $notification_id] ); if (!$updated) { throw new Exception('Failed to update notification'); } return $updated; }); $this->trackMetrics($notification_id, $user_id, 'dismissed'); $this->clearUserCache($user_id); return $this->success(['updated' => $result]); } catch (Exception $e) { return $this->error($e->getMessage()); } } /** * Mark content notification as read */ public function markContentNotificationRead(int $seen_id, int $user_id): array { if (!$this->checkUser($seen_id) || !$this->checkUser($user_id)) { return [ 'success' => false, 'message' => 'Invalid User ID' ]; } try { global $wpdb; $table = $wpdb->prefix . BASE . 'notifications_user_seen'; return $wpdb->query('START TRANSACTION') && $this->updateContentNotificationStatus($table, $seen_id, $user_id, 'read'); } catch (Exception $e) { global $wpdb; $wpdb->query('ROLLBACK'); $this->logError('markContentNotificationRead exception', [ 'seen_id' => $seen_id, 'user_id' => $user_id, 'error' => $e->getMessage() ]); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Dismiss content notification */ public function dismissContentNotification(int $seen_id, int $user_id): array { if (!$this->checkUser($seen_id) || !$this->checkUser($user_id)) { return [ 'success' => false, 'message' => 'Invalid User ID' ]; } try { global $wpdb; $table = $wpdb->prefix . BASE . 'notifications_user_seen'; // Verify ownership $seen_record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND user_id = %d", $seen_id, $user_id ) ); if (!$seen_record) { return [ 'success' => false, 'message' => 'No notification found' ]; } $result = $wpdb->update( $table, ['status' => 'dismissed'], ['id' => $seen_id] ); if ($result !== false) { $this->clearUserCache($user_id); } return [ 'success' => $result !== false, 'message' => 'Operation completed', ]; } catch (Exception $e) { return [ 'success' => false, 'message' => $e->getMessage() ]; } } // ========================================================================= // QUEUE OPERATIONS // ========================================================================= /** * Process queued notification operations */ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array { switch ($operation->type) { case 'notification_mark_as_read': $result = $this->markReadQueued($data['notification_id'], $operation->user_id); break; case 'notification_mark_all_as_read': $result = $this->markAllReadQueued($data); break; case 'notification_dismiss_notification': $result = $this->markDismissedQueued($data['notification_id'], $operation->user_id); break; } return $result; } /** * Update notification operations (legacy endpoint) */ public function updateNotifications(WP_REST_Request $request): WP_REST_Response { $data = $request->get_params(); $action = $request->get_param('action'); $notificationID = absint($data['notification'] ?? 0); $args = $this->buildParams($request); $queueData = []; $error = ''; switch ($action) { case 'mark_as_read': if (!$notificationID) { return $this->validationError(['notification' => 'Notification ID is required']); } $queueData = [ 'user_id' => $args['user_id'], 'notification_id' => $notificationID, ]; break; case 'mark_all_as_read': $queueData = ['user_id' => $args['user_id']]; if ($request->get_param('notification_ids')) { $queueData['notification_ids'] = array_map('intval', $request->get_param('notification_ids')); } if ($request->get_param('type')) { $queueData['type'] = $request->get_param('type'); } break; case 'dismiss_notification': $queueData = [ 'user_id' => $args['user_id'], 'notification_id' => $notificationID, ]; break; default: $error = 'Invalid action'; } if (!empty($queueData)) { JVB()->queue()->queueOperation( 'notification_' . $action, $args['user_id'], $queueData ); return $this->success(['message' => __('Notification queued for processing', 'jvb')]); } return $this->error($error ?: 'Unknown error'); } // ========================================================================= // FORMATTING HELPERS // ========================================================================= /** * Format a notification for display */ 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, 'actions' => $this->getNotificationActions($notification->type, (array)$notification, $notification) ]; } /** * Format an approval request as a 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 = $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 ] ]; } /** * Format a content notification for display */ 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 ]; } /** * Build a message for grouped notifications */ protected function buildGroupedMessage(string $user_name, string $type, int $count, array $target_types = []): string { switch ($type) { case 'new_favourite': 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"; default: return "{$user_name} has {$count} notifications for you"; } } // ========================================================================= // HELPER METHODS // ========================================================================= /** * Get notification actions */ protected function getNotificationActions(string $type, array $data, object $notification = null): array { $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 '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: if (!empty($data['target_id']) && !empty($data['target_type'])) { $actions[] = [ 'icon' => 'link', 'label' => 'View', 'url' => $this->getItemLink($data['target_id'], $data['target_type']), ]; } break; } $actions[] = [ 'icon' => 'close', 'label' => 'Dismiss', 'action' => 'dismiss_notification' ]; return apply_filters('jvb_notification_actions', $actions, $type, $data, $notification); } /** * Get item link for notification target */ 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 data by ID */ protected function getNotification(int $notification_id): array|false { $notification = $this->notifications->where(['id' => $notification_id])->first(); if (!$notification) { return false; } $context = !empty($notification->context) ? json_decode($notification->context, true) : []; return array_merge((array)$notification, $context); } /** * Build notification request parameters */ protected function buildParams(WP_REST_Request $request): array { $params = $request->get_params(); return [ 'status' => in_array($params['status'] ?? '', ['all', 'unread', 'expired']) ? $params['status'] : 'unread', 'user_id' => absint($params['user'] ?? get_current_user_id()), 'page' => absint($params['page'] ?? 1), 'type' => in_array($params['type'] ?? '', array_keys($this->manager->notification_types ?? [])) ? $params['type'] : 'all', ]; } /** * Determine which table a notification type belongs to */ protected function getTableForNotificationType(string $notificationType): string { foreach ($this->notificationTableMap as $table => $types) { if (in_array($notificationType, $types)) { return $table; } } return 'notifications'; } /** * Simple pluralization helper */ 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]; } if (substr($word, -1) === 'y') { return substr($word, 0, -1) . 'ies'; } return $word . 's'; } /** * Track notification metrics */ protected function trackMetrics(int $notification_id, int $user_id, string $action, array $details = []): void { try { $this->metrics->create([ 'notification_id' => $notification_id, 'user_id' => $user_id, 'action' => $action, 'action_source' => 'web', 'action_details' => !empty($details) ? json_encode($details) : null, ]); } catch (Exception $e) { // Don't fail the request if metrics tracking fails $this->logError('trackMetrics failed', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'action' => $action ]); } } /** * Clear notification cache for a user */ protected function clearUserCache(int $user_id): void { Cache::invalidateItem('notifications', $user_id); } /** * Update content notification status (helper for transactions) */ private function updateContentNotificationStatus(string $table, int $seen_id, int $user_id, string $status): array { global $wpdb; // 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'); return [ 'success' => false, 'message' => 'Seen record not found or not owned by user' ]; } // Update status $result = $wpdb->update( $table, [ 'status' => $status, 'read_at' => current_time('mysql') ], ['id' => $seen_id] ); if ($result !== false) { $wpdb->query('COMMIT'); $this->clearUserCache($user_id); return [ 'success' => true, 'message' => 'Successfully marked as ' . $status ]; } $wpdb->query('ROLLBACK'); return [ 'success' => false, 'message' => 'Database error' ]; } /** * Queued operation helpers (for backwards compatibility) */ private function markReadQueued(int $notification_id, int $user_id): array { $updated = $this->notifications->update( ['status' => 'read', 'read_at' => current_time('mysql')], ['id' => $notification_id, 'owner_id' => $user_id] ); if ($updated) { $this->clearUserCache($user_id); } return ['success' => $updated !== false]; } private function markAllReadQueued(array $data): array { $where = ['owner_id' => $data['user_id'], 'status' => 'unread']; if (!empty($data['type'])) { $where['type'] = $data['type']; } $updated = $this->notifications ->where($where) ->updateResults(['status' => 'read', 'read_at' => current_time('mysql')]); if ($updated) { $this->clearUserCache($data['user_id']); } return ['success' => $updated !== false, 'count' => $updated]; } private function markDismissedQueued(int $notification_id, int $user_id): array { $updated = $this->notifications->update( ['status' => 'dismissed', 'dismissed_at' => current_time('mysql')], ['id' => $notification_id, 'owner_id' => $user_id] ); if ($updated) { $this->clearUserCache($user_id); } return ['success' => $updated !== false]; } }