<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\managers\Cache;
|
use JVBase\managers\CustomTable;
|
use JVBase\rest\Rest;
|
use JVBase\rest\Route;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
/**
|
* Notification Routes Handler
|
*
|
* Manages user notifications including regular notifications, content notifications,
|
* and approval notifications. Provides endpoints for reading, marking, and dismissing.
|
*/
|
class NotificationsRoutes extends Rest
|
{
|
protected int $user_id;
|
protected array $notification_types = [];
|
protected object $manager;
|
|
protected array $typeMap = [
|
'favourite' => [
|
'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();
|
}
|
|
/**
|
* 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 = jvbApprovalTypes();
|
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];
|
}
|
}
|