| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Controllers; |
| 6 |
|
| 7 |
use WP_REST_Request; |
| 8 |
use WP_REST_Response; |
| 9 |
use Yatra\Database\Tables\BookingsTable; |
| 10 |
use Yatra\Database\Tables\BookingPaymentsTable; |
| 11 |
use Yatra\Database\Tables\EnquiriesTable; |
| 12 |
use Yatra\Database\Tables\ReviewsTable; |
| 13 |
|
| 14 |
/** |
| 15 |
* Admin sidebar "new since you last looked" counters. |
| 16 |
* |
| 17 |
* Powers the red count badges on the Bookings / Payments / Abandoned Recovery |
| 18 |
* sidebar items. Purely additive and read-only against the existing tables: |
| 19 |
* |
| 20 |
* - The "last seen" marker for each section lives in `wp_options` |
| 21 |
* (`yatra_<section>_last_seen_id`). NO existing table is altered. |
| 22 |
* - A badge count = number of rows in that section's table with `id` greater |
| 23 |
* than the stored marker (i.e. created after the admin last opened the page). |
| 24 |
* - Opening a page calls mark-seen, which bumps the marker to the current |
| 25 |
* MAX(id) so the badge clears. |
| 26 |
* |
| 27 |
* Abandoned Recovery is a Pro feature, so its count/marker are contributed by |
| 28 |
* Pro through the `yatra_admin_new_counts` filter and `yatra_admin_mark_seen` |
| 29 |
* action — this controller knows nothing about Pro. |
| 30 |
*/ |
| 31 |
class NotificationCountsController extends BaseController |
| 32 |
{ |
| 33 |
/** section => wp_option name (sections handled directly by Free core). */ |
| 34 |
private const CORE_SECTIONS = [ |
| 35 |
'bookings' => 'yatra_bookings_last_seen_id', |
| 36 |
'payments' => 'yatra_payments_last_seen_id', |
| 37 |
'enquiries' => 'yatra_enquiries_last_seen_id', |
| 38 |
'reviews' => 'yatra_reviews_last_seen_id', |
| 39 |
]; |
| 40 |
|
| 41 |
public function register_routes(): void |
| 42 |
{ |
| 43 |
register_rest_route($this->namespace, '/admin/new-counts', [ |
| 44 |
'methods' => 'GET', |
| 45 |
'callback' => [$this, 'get_counts'], |
| 46 |
'permission_callback' => [$this, 'check_permission'], |
| 47 |
]); |
| 48 |
|
| 49 |
register_rest_route($this->namespace, '/admin/mark-seen', [ |
| 50 |
'methods' => 'POST', |
| 51 |
'callback' => [$this, 'mark_seen'], |
| 52 |
'permission_callback' => [$this, 'check_permission'], |
| 53 |
'args' => [ |
| 54 |
'section' => [ |
| 55 |
'required' => true, |
| 56 |
'type' => 'string', |
| 57 |
], |
| 58 |
], |
| 59 |
]); |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Anyone who can reach the Yatra admin can read these counters. The sidebar |
| 64 |
* already hides items the user lacks the cap for, so unseen-counts for a |
| 65 |
* section they can't view are simply never displayed. |
| 66 |
*/ |
| 67 |
public function check_permission(?WP_REST_Request $request = null): bool |
| 68 |
{ |
| 69 |
$cap = (string) apply_filters('yatra_admin_menu_cap', 'yatra_access_admin'); |
| 70 |
|
| 71 |
return current_user_can($cap) || current_user_can('manage_options'); |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* GET /admin/new-counts → { success, counts: { bookings, payments, abandoned? } } |
| 76 |
*/ |
| 77 |
public function get_counts(WP_REST_Request $request): WP_REST_Response |
| 78 |
{ |
| 79 |
$counts = [ |
| 80 |
'bookings' => $this->newCount(BookingsTable::getTableName(), self::CORE_SECTIONS['bookings']), |
| 81 |
'payments' => $this->newCount(BookingPaymentsTable::getTableName(), self::CORE_SECTIONS['payments']), |
| 82 |
'enquiries' => $this->newCount(EnquiriesTable::getTableName(), self::CORE_SECTIONS['enquiries']), |
| 83 |
'reviews' => $this->newCount(ReviewsTable::getTableName(), self::CORE_SECTIONS['reviews']), |
| 84 |
]; |
| 85 |
|
| 86 |
/** |
| 87 |
* Let Pro (and future modules) contribute their own section counts, |
| 88 |
* e.g. 'abandoned'. Values are normalised to non-negative ints below. |
| 89 |
* |
| 90 |
* @param array<string,int> $counts section => new-row count |
| 91 |
*/ |
| 92 |
$counts = (array) apply_filters('yatra_admin_new_counts', $counts); |
| 93 |
|
| 94 |
$counts = array_map(static function ($v): int { |
| 95 |
return max(0, (int) $v); |
| 96 |
}, $counts); |
| 97 |
|
| 98 |
return new WP_REST_Response(['success' => true, 'counts' => $counts], 200); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* POST /admin/mark-seen { section } → bumps that section's marker to MAX(id). |
| 103 |
*/ |
| 104 |
public function mark_seen(WP_REST_Request $request): WP_REST_Response |
| 105 |
{ |
| 106 |
$section = sanitize_key((string) $request->get_param('section')); |
| 107 |
|
| 108 |
$tableMap = [ |
| 109 |
'bookings' => BookingsTable::getTableName(), |
| 110 |
'payments' => BookingPaymentsTable::getTableName(), |
| 111 |
'enquiries' => EnquiriesTable::getTableName(), |
| 112 |
'reviews' => ReviewsTable::getTableName(), |
| 113 |
]; |
| 114 |
|
| 115 |
if (isset(self::CORE_SECTIONS[$section], $tableMap[$section])) { |
| 116 |
$this->updateMarker(self::CORE_SECTIONS[$section], $tableMap[$section]); |
| 117 |
} else { |
| 118 |
/** |
| 119 |
* Non-core sections (e.g. Pro's 'abandoned') persist their own marker. |
| 120 |
* |
| 121 |
* @param string $section sanitised section key |
| 122 |
*/ |
| 123 |
do_action('yatra_admin_mark_seen', $section); |
| 124 |
} |
| 125 |
|
| 126 |
return new WP_REST_Response(['success' => true, 'section' => $section], 200); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* New-row count for a section, with two go-live safeguards: |
| 131 |
* |
| 132 |
* - Missing table (fresh / partial install): returns 0 instead of letting |
| 133 |
* `SELECT COUNT(*)` raise a "table doesn't exist" DB error into debug.log. |
| 134 |
* - First run on a site (marker never set): seeds the marker to the current |
| 135 |
* MAX(id) and returns 0, so pre-existing history is NOT reported as "new". |
| 136 |
* Only rows created after the feature goes live are counted. |
| 137 |
* |
| 138 |
* Table name comes from our own *Table::getTableName(), never user input. |
| 139 |
*/ |
| 140 |
private function newCount(string $table, string $option): int |
| 141 |
{ |
| 142 |
if (!$this->tableExists($table)) { |
| 143 |
return 0; |
| 144 |
} |
| 145 |
|
| 146 |
$marker = get_option($option, null); |
| 147 |
if ($marker === null) { |
| 148 |
// Never seen on this site → seed to current max, report nothing new. |
| 149 |
$this->updateMarker($option, $table); |
| 150 |
return 0; |
| 151 |
} |
| 152 |
|
| 153 |
global $wpdb; |
| 154 |
|
| 155 |
return (int) $wpdb->get_var( |
| 156 |
$wpdb->prepare("SELECT COUNT(*) FROM `{$table}` WHERE id > %d", (int) $marker) |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
/** Bump a section's marker to the current highest id (badge → 0). */ |
| 161 |
private function updateMarker(string $option, string $table): void |
| 162 |
{ |
| 163 |
if (!$this->tableExists($table)) { |
| 164 |
return; |
| 165 |
} |
| 166 |
|
| 167 |
global $wpdb; |
| 168 |
|
| 169 |
$maxId = (int) $wpdb->get_var("SELECT COALESCE(MAX(id), 0) FROM `{$table}`"); |
| 170 |
update_option($option, $maxId, false); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Does a table exist? Memoised per request (these endpoints touch the same |
| 175 |
* tables 4–5×) so we never spam SHOW TABLES or COUNT a missing table. |
| 176 |
* |
| 177 |
* @var array<string,bool> |
| 178 |
*/ |
| 179 |
private static $tableExistsCache = []; |
| 180 |
|
| 181 |
private function tableExists(string $table): bool |
| 182 |
{ |
| 183 |
if (!array_key_exists($table, self::$tableExistsCache)) { |
| 184 |
global $wpdb; |
| 185 |
$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); |
| 186 |
self::$tableExistsCache[$table] = ($found === $table); |
| 187 |
} |
| 188 |
|
| 189 |
return self::$tableExistsCache[$table]; |
| 190 |
} |
| 191 |
} |
| 192 |
|