| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentBooking\App\Modules\MCP\Tools; |
| 4 |
|
| 5 |
use FluentBooking\App\Models\Booking; |
| 6 |
use FluentBooking\App\Models\Calendar; |
| 7 |
use FluentBooking\App\Modules\MCP\Support\BookingProjector; |
| 8 |
use FluentBooking\App\Modules\MCP\Support\MCPHelper; |
| 9 |
use FluentBooking\App\Modules\MCP\Support\PermissionGate; |
| 10 |
use FluentBooking\App\Services\PermissionManager; |
| 11 |
use FluentBooking\Framework\Support\Arr; |
| 12 |
|
| 13 |
defined('ABSPATH') || exit; |
| 14 |
|
| 15 |
/** |
| 16 |
* Reading bookings — the surface an agent spends most of its calls on. |
| 17 |
* |
| 18 |
* Two tools rather than five. Cal.com ships separate tools for a booking's |
| 19 |
* attendees; here that is an `include` value on `get-booking`, because the |
| 20 |
* parameter shape is identical and a separate tool would cost another ~500 |
| 21 |
* tokens of permanently-resident schema to save one round-trip nobody makes. |
| 22 |
* |
| 23 |
* Scoping is done in the query, never in the response. A host without |
| 24 |
* read-all-bookings permission gets a query that cannot see other hosts' rows |
| 25 |
* at all, so counts, pagination totals and results are all consistent with what |
| 26 |
* they are allowed to know. Filtering after the fact leaks the totals. |
| 27 |
*/ |
| 28 |
class BookingTools |
| 29 |
{ |
| 30 |
const DEFAULT_PER_PAGE = 20; |
| 31 |
|
| 32 |
/** |
| 33 |
* `include` values get-booking accepts. Each one costs a query or an |
| 34 |
* unserialize, which is exactly why none of them are on by default. |
| 35 |
*/ |
| 36 |
const INCLUDABLE = ['custom_fields', 'attendees', 'hosts', 'activities']; |
| 37 |
|
| 38 |
public static function definitions() |
| 39 |
{ |
| 40 |
return [ |
| 41 |
'fluent-booking/list-bookings' => [ |
| 42 |
'label' => __('List bookings', 'fluent-booking'), |
| 43 |
'description' => __('List bookings with filters. Returns a compact row per booking; attendee emails are masked unless include_pii is set. Use get-booking for one booking in full.', 'fluent-booking'), |
| 44 |
'input_schema' => [ |
| 45 |
'type' => 'object', |
| 46 |
'properties' => [ |
| 47 |
'period' => [ |
| 48 |
'type' => 'string', |
| 49 |
'description' => __('Computed bucket. Defaults to upcoming.', 'fluent-booking'), |
| 50 |
'enum' => ContextTools::bookingPeriods(), |
| 51 |
], |
| 52 |
'status' => [ |
| 53 |
'type' => 'array', |
| 54 |
'description' => __('Filter by raw status values instead of a period bucket.', 'fluent-booking'), |
| 55 |
'items' => [ |
| 56 |
'type' => 'string', |
| 57 |
'enum' => ContextTools::bookingStatuses(), |
| 58 |
], |
| 59 |
], |
| 60 |
'calendar_id' => ['type' => 'integer'], |
| 61 |
'event_id' => ['type' => 'integer'], |
| 62 |
'event_type' => [ |
| 63 |
'type' => 'string', |
| 64 |
'enum' => ContextTools::eventTypes(), |
| 65 |
], |
| 66 |
'host_id' => ['type' => 'integer'], |
| 67 |
'email' => [ |
| 68 |
'type' => 'string', |
| 69 |
'description' => __('Exact attendee email match.', 'fluent-booking'), |
| 70 |
], |
| 71 |
'search' => [ |
| 72 |
'type' => 'string', |
| 73 |
'description' => __('Free-text search across attendee name, email and phone.', 'fluent-booking'), |
| 74 |
], |
| 75 |
'from' => [ |
| 76 |
'type' => 'string', |
| 77 |
'description' => __('Start of the booking date range, Y-m-d.', 'fluent-booking'), |
| 78 |
], |
| 79 |
'to' => [ |
| 80 |
'type' => 'string', |
| 81 |
'description' => __('End of the booking date range, Y-m-d.', 'fluent-booking'), |
| 82 |
], |
| 83 |
'timezone' => [ |
| 84 |
'type' => 'string', |
| 85 |
'description' => __('IANA timezone for the *_local times in the response. Defaults to the site timezone.', 'fluent-booking'), |
| 86 |
], |
| 87 |
'group_bookings' => [ |
| 88 |
'type' => 'boolean', |
| 89 |
'description' => __('Collapse group bookings to one row, matching the admin list. Default true.', 'fluent-booking'), |
| 90 |
], |
| 91 |
'include_pii' => [ |
| 92 |
'type' => 'boolean', |
| 93 |
'description' => __('Return unmasked attendee emails. Requires read access to all bookings.', 'fluent-booking'), |
| 94 |
], |
| 95 |
'page' => ['type' => 'integer'], |
| 96 |
'per_page' => [ |
| 97 |
'type' => 'integer', |
| 98 |
'description' => sprintf( |
| 99 |
/* translators: %1$d: default page size, %2$d: maximum page size */ |
| 100 |
__('Default %1$d, maximum %2$d.', 'fluent-booking'), |
| 101 |
self::DEFAULT_PER_PAGE, |
| 102 |
MCPHelper::MAX_PER_PAGE |
| 103 |
), |
| 104 |
], |
| 105 |
], |
| 106 |
], |
| 107 |
'annotations' => [ |
| 108 |
'title' => __('List bookings', 'fluent-booking'), |
| 109 |
'readonly' => true, |
| 110 |
], |
| 111 |
'permission_callback' => [PermissionGate::class, 'readGate'], |
| 112 |
'execute_callback' => [self::class, 'listBookings'], |
| 113 |
], |
| 114 |
|
| 115 |
'fluent-booking/get-booking' => [ |
| 116 |
'label' => __('Get booking', 'fluent-booking'), |
| 117 |
'description' => __('Full detail for one booking by id or hash, including attendee contact details, location, status history and cancellation reason. Use include to add form answers, guests, hosts or the activity timeline.', 'fluent-booking'), |
| 118 |
'input_schema' => [ |
| 119 |
'type' => 'object', |
| 120 |
'properties' => [ |
| 121 |
'booking_id' => ['type' => 'integer'], |
| 122 |
'hash' => [ |
| 123 |
'type' => 'string', |
| 124 |
'description' => __('The booking hash, as an alternative to booking_id.', 'fluent-booking'), |
| 125 |
], |
| 126 |
'include' => [ |
| 127 |
'type' => 'array', |
| 128 |
'description' => __('Extra sections to load. Each one costs an additional query.', 'fluent-booking'), |
| 129 |
'items' => [ |
| 130 |
'type' => 'string', |
| 131 |
'enum' => self::INCLUDABLE, |
| 132 |
], |
| 133 |
], |
| 134 |
'timezone' => [ |
| 135 |
'type' => 'string', |
| 136 |
'description' => __('IANA timezone for the *_local times in the response.', 'fluent-booking'), |
| 137 |
], |
| 138 |
], |
| 139 |
], |
| 140 |
'annotations' => [ |
| 141 |
'title' => __('Get booking', 'fluent-booking'), |
| 142 |
'readonly' => true, |
| 143 |
], |
| 144 |
'permission_callback' => [PermissionGate::class, 'readGate'], |
| 145 |
'execute_callback' => [self::class, 'getBooking'], |
| 146 |
], |
| 147 |
]; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* @param array $params |
| 152 |
* @return array|\WP_Error |
| 153 |
*/ |
| 154 |
public static function listBookings($params = []) |
| 155 |
{ |
| 156 |
$timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); |
| 157 |
|
| 158 |
$seesAll = PermissionGate::canSeeAllBookings(); |
| 159 |
|
| 160 |
$query = self::buildQuery($params, $seesAll, $timezone); |
| 161 |
|
| 162 |
if (is_wp_error($query)) { |
| 163 |
return $query; |
| 164 |
} |
| 165 |
|
| 166 |
// The admin list collapses group bookings on group_id so a ten-attendee |
| 167 |
// group event reads as one booking rather than ten. Default to the same |
| 168 |
// thing: an agent that reports a different number than the operator's |
| 169 |
// screen is worse than useless. |
| 170 |
$grouped = (bool) Arr::get($params, 'group_bookings', true); |
| 171 |
|
| 172 |
// ...except when searching. GROUP BY keeps one arbitrary row per group, |
| 173 |
// so a term matching two attendees of the same group could drop the |
| 174 |
// exact match in favour of the weaker one beside it. |
| 175 |
$searchCollapsed = $grouped && trim((string) Arr::get($params, 'search', '')) !== ''; |
| 176 |
|
| 177 |
if ($searchCollapsed) { |
| 178 |
$grouped = false; |
| 179 |
} |
| 180 |
|
| 181 |
$perPage = MCPHelper::perPage(Arr::get($params, 'per_page'), self::DEFAULT_PER_PAGE); |
| 182 |
$page = max(1, absint(Arr::get($params, 'page', 1))); |
| 183 |
|
| 184 |
// Count BEFORE the groupBy is applied. COUNT() over a grouped query |
| 185 |
// returns the size of the first group, not the number of groups — which |
| 186 |
// reads as a plausible small number rather than an error, so an agent |
| 187 |
// would report "1 booking" over a page of seventeen and never know. |
| 188 |
// Mirrors SchedulesController::addCountsForFirstPage(). |
| 189 |
$total = $grouped |
| 190 |
? (clone $query)->withoutEagerLoads()->distinct('group_id')->count('group_id') |
| 191 |
: (clone $query)->withoutEagerLoads()->count(); |
| 192 |
|
| 193 |
if ($grouped) { |
| 194 |
$query->groupBy('group_id'); |
| 195 |
} |
| 196 |
|
| 197 |
$bookings = $query->with(BookingProjector::rowRelations()) |
| 198 |
->skip(($page - 1) * $perPage) |
| 199 |
->take($perPage) |
| 200 |
->get(); |
| 201 |
|
| 202 |
// Unmasked emails are a read-all-bookings privilege. Asking for them |
| 203 |
// without that permission is not an error — the rows are still useful — |
| 204 |
// so the request is downgraded and the response says it was. |
| 205 |
$wantsPii = (bool) Arr::get($params, 'include_pii', false); |
| 206 |
$includePii = $wantsPii && $seesAll; |
| 207 |
|
| 208 |
$rows = []; |
| 209 |
|
| 210 |
foreach ($bookings as $booking) { |
| 211 |
$rows[] = BookingProjector::row($booking, $timezone, $includePii); |
| 212 |
} |
| 213 |
|
| 214 |
$meta = array_merge( |
| 215 |
MCPHelper::paginationMeta($total, $page, $perPage), |
| 216 |
[ |
| 217 |
'timezone' => $timezone, |
| 218 |
'scope' => PermissionGate::currentScope(), |
| 219 |
] |
| 220 |
); |
| 221 |
|
| 222 |
if ($wantsPii && !$includePii) { |
| 223 |
$meta['pii_masked'] = true; |
| 224 |
} |
| 225 |
|
| 226 |
if ($searchCollapsed) { |
| 227 |
$meta['group_bookings'] = false; |
| 228 |
$meta['group_bookings_note'] = __('Group bookings are listed per attendee here rather than collapsed, so a search cannot hide an attendee behind a group-mate. Counts will be higher than the admin list for group events.', 'fluent-booking'); |
| 229 |
} |
| 230 |
|
| 231 |
return MCPHelper::success($rows, $meta); |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* @param array $params |
| 236 |
* @return array|\WP_Error |
| 237 |
*/ |
| 238 |
public static function getBooking($params = []) |
| 239 |
{ |
| 240 |
$timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); |
| 241 |
|
| 242 |
$bookingId = absint(Arr::get($params, 'booking_id')); |
| 243 |
$hash = sanitize_text_field((string) Arr::get($params, 'hash', '')); |
| 244 |
|
| 245 |
if (!$bookingId && !$hash) { |
| 246 |
return MCPHelper::error( |
| 247 |
'missing_identifier', |
| 248 |
__('Pass either booking_id or hash.', 'fluent-booking') |
| 249 |
); |
| 250 |
} |
| 251 |
|
| 252 |
$query = Booking::query(); |
| 253 |
|
| 254 |
if ($bookingId) { |
| 255 |
$query->where('id', $bookingId); |
| 256 |
} else { |
| 257 |
$query->where('hash', $hash); |
| 258 |
} |
| 259 |
|
| 260 |
$booking = $query->with(BookingProjector::rowRelations())->first(); |
| 261 |
|
| 262 |
if (!$booking) { |
| 263 |
return MCPHelper::error( |
| 264 |
'booking_not_found', |
| 265 |
__('No booking matched that identifier.', 'fluent-booking') |
| 266 |
); |
| 267 |
} |
| 268 |
|
| 269 |
// Ownership, not just capability: a host with only their own access may |
| 270 |
// hold a valid booking id belonging to somebody else. |
| 271 |
if (!self::canReadBooking($booking)) { |
| 272 |
return MCPHelper::error( |
| 273 |
'permission_denied', |
| 274 |
__('You do not have access to this booking.', 'fluent-booking') |
| 275 |
); |
| 276 |
} |
| 277 |
|
| 278 |
$include = Arr::get($params, 'include', []); |
| 279 |
$include = is_array($include) ? array_intersect($include, self::INCLUDABLE) : []; |
| 280 |
|
| 281 |
return MCPHelper::success( |
| 282 |
BookingProjector::full($booking, $timezone, $include), |
| 283 |
[ |
| 284 |
'timezone' => $timezone, |
| 285 |
'scope' => PermissionGate::currentScope(), |
| 286 |
] |
| 287 |
); |
| 288 |
} |
| 289 |
|
| 290 |
/** |
| 291 |
* True when the caller may read this specific booking. |
| 292 |
* |
| 293 |
* Deliberately the SAME test `list-bookings` scopes its query with — |
| 294 |
* `Booking::whereHostAccess()`, i.e. own the calendar or be a host on this |
| 295 |
* booking. It used to fall back to `PermissionManager::canReadCalendar()`, |
| 296 |
* which is a broader question than it sounds: `canReadCalendar()` treats a |
| 297 |
* calendar as readable if the caller is a team member on *any one* of its |
| 298 |
* event types, so on a shared team calendar it returned true for every |
| 299 |
* booking on every other event type too. |
| 300 |
* |
| 301 |
* The effect was a single-record read that was wider than the list beside |
| 302 |
* it, on sequential integer ids, while still stamping the response |
| 303 |
* `scope: own_calendars`. An agent that cannot see a booking in |
| 304 |
* `list-bookings` must not be able to open it by guessing its id. |
| 305 |
* |
| 306 |
* @param Booking $booking |
| 307 |
* @return bool |
| 308 |
*/ |
| 309 |
private static function canReadBooking(Booking $booking) |
| 310 |
{ |
| 311 |
if (PermissionGate::canSeeAllBookings()) { |
| 312 |
return true; |
| 313 |
} |
| 314 |
|
| 315 |
$userId = (int) get_current_user_id(); |
| 316 |
|
| 317 |
if ((int) $booking->host_user_id === $userId) { |
| 318 |
return true; |
| 319 |
} |
| 320 |
|
| 321 |
if (in_array($userId, array_map('intval', (array) $booking->getHostIds()), true)) { |
| 322 |
return true; |
| 323 |
} |
| 324 |
|
| 325 |
// Calendar ownership, matching whereHostAccess()'s first branch. Not |
| 326 |
// canReadCalendar(): that also admits shared calendars. |
| 327 |
return (bool) Calendar::where('id', $booking->calendar_id) |
| 328 |
->where('user_id', $userId) |
| 329 |
->exists(); |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Compose the list query from the filters, scoped to what the caller may see. |
| 334 |
* |
| 335 |
* Every filter here delegates to an existing Booking scope, so MCP results |
| 336 |
* and the admin schedules list are produced by the same code — the two |
| 337 |
* cannot drift into disagreeing about what "upcoming" or "cancelled" means. |
| 338 |
* |
| 339 |
* @param array $params |
| 340 |
* @param bool $seesAll |
| 341 |
* @param string $timezone resolved IANA identifier the from/to dates are read in |
| 342 |
* @return object|\WP_Error |
| 343 |
*/ |
| 344 |
private static function buildQuery($params, $seesAll, $timezone) |
| 345 |
{ |
| 346 |
$query = Booking::query(); |
| 347 |
|
| 348 |
if (!$seesAll) { |
| 349 |
$query->whereHostAccess(get_current_user_id()); |
| 350 |
} |
| 351 |
|
| 352 |
$hostId = absint(Arr::get($params, 'host_id')); |
| 353 |
|
| 354 |
if ($hostId) { |
| 355 |
$query->where('host_user_id', $hostId); |
| 356 |
} |
| 357 |
|
| 358 |
$calendarId = absint(Arr::get($params, 'calendar_id')); |
| 359 |
|
| 360 |
if ($calendarId) { |
| 361 |
$query->where('calendar_id', $calendarId); |
| 362 |
} |
| 363 |
|
| 364 |
$eventId = absint(Arr::get($params, 'event_id')); |
| 365 |
|
| 366 |
if ($eventId) { |
| 367 |
$query->where('event_id', $eventId); |
| 368 |
} |
| 369 |
|
| 370 |
$eventType = sanitize_text_field((string) Arr::get($params, 'event_type', '')); |
| 371 |
|
| 372 |
if ($eventType) { |
| 373 |
$query->where('event_type', $eventType); |
| 374 |
} |
| 375 |
|
| 376 |
$email = sanitize_email((string) Arr::get($params, 'email', '')); |
| 377 |
|
| 378 |
if ($email && is_email($email)) { |
| 379 |
$query->where('email', $email); |
| 380 |
} |
| 381 |
|
| 382 |
$range = self::dateRange($params, $timezone); |
| 383 |
|
| 384 |
if (is_wp_error($range)) { |
| 385 |
return $range; |
| 386 |
} |
| 387 |
|
| 388 |
if ($range) { |
| 389 |
$query->applyDateRangeFilter($range); |
| 390 |
} |
| 391 |
|
| 392 |
// Applied before the status/period branch below, because that branch |
| 393 |
// returns early: a search silently dropped whenever `status` was also |
| 394 |
// passed produced a full unfiltered result set that reads exactly like |
| 395 |
// a successful search, which is the one failure mode this module is |
| 396 |
// least able to recover from. |
| 397 |
$search = sanitize_text_field((string) Arr::get($params, 'search', '')); |
| 398 |
|
| 399 |
if ($search) { |
| 400 |
$query->searchBy($search); |
| 401 |
} |
| 402 |
|
| 403 |
// A raw status filter and a period bucket answer different questions |
| 404 |
// ("rows whose column says cancelled" vs "rows the admin shows under |
| 405 |
// Cancelled"), so an explicit status list wins rather than being ANDed |
| 406 |
// into a contradiction that silently returns nothing. |
| 407 |
$statuses = Arr::get($params, 'status', []); |
| 408 |
$statuses = is_array($statuses) ? array_filter(array_map('sanitize_text_field', $statuses)) : []; |
| 409 |
|
| 410 |
if ($statuses) { |
| 411 |
$query->whereIn('status', $statuses); |
| 412 |
$query->orderBy('start_time', 'DESC'); |
| 413 |
|
| 414 |
return $query; |
| 415 |
} |
| 416 |
|
| 417 |
$period = sanitize_text_field((string) Arr::get($params, 'period', 'upcoming')); |
| 418 |
|
| 419 |
if (!in_array($period, ContextTools::bookingPeriods(), true)) { |
| 420 |
$period = 'upcoming'; |
| 421 |
} |
| 422 |
|
| 423 |
$query->applyComputedStatus($period); |
| 424 |
$query->applyBookingOrderByStatus($period); |
| 425 |
|
| 426 |
return $query; |
| 427 |
} |
| 428 |
|
| 429 |
/** |
| 430 |
* Translate the caller's from/to dates into the UTC window they mean. |
| 431 |
* |
| 432 |
* `start_time` is stored in UTC, but "bookings on 2026-08-24" is a question |
| 433 |
* about a local calendar day. Matching the UTC column against a bare |
| 434 |
* '2026-08-24 00:00:00'–'23:59:59' answers a question up to fourteen hours |
| 435 |
* out of alignment with the one asked: in America/Los_Angeles it silently |
| 436 |
* drops everything from 5pm Monday onward and folds in Sunday evening |
| 437 |
* instead. The dates are therefore read in the same timezone the response |
| 438 |
* renders its *_local times in. |
| 439 |
* |
| 440 |
* @param array $params |
| 441 |
* @param string $timezone resolved IANA identifier |
| 442 |
* @return array|\WP_Error [] when unbounded |
| 443 |
*/ |
| 444 |
private static function dateRange($params, $timezone) |
| 445 |
{ |
| 446 |
$from = sanitize_text_field((string) Arr::get($params, 'from', '')); |
| 447 |
$to = sanitize_text_field((string) Arr::get($params, 'to', '')); |
| 448 |
|
| 449 |
if (!$from && !$to) { |
| 450 |
return []; |
| 451 |
} |
| 452 |
|
| 453 |
foreach ([$from, $to] as $date) { |
| 454 |
if ($date && !MCPHelper::isRealDate($date)) { |
| 455 |
return MCPHelper::error( |
| 456 |
'invalid_date', |
| 457 |
__('from and to must be real dates in Y-m-d form.', 'fluent-booking'), |
| 458 |
['received' => $date] |
| 459 |
); |
| 460 |
} |
| 461 |
} |
| 462 |
|
| 463 |
// An open-ended bound is a usable question; fill the other end rather |
| 464 |
// than rejecting it. |
| 465 |
$start = $from ? MCPHelper::dayBoundaryToUtc($from, $timezone, false) : '1970-01-01 00:00:00'; |
| 466 |
$end = $to ? MCPHelper::dayBoundaryToUtc($to, $timezone, true) : '2999-12-31 23:59:59'; |
| 467 |
|
| 468 |
if ($end < $start) { |
| 469 |
return MCPHelper::error( |
| 470 |
'invalid_range', |
| 471 |
__('The end of the range is before its start.', 'fluent-booking') |
| 472 |
); |
| 473 |
} |
| 474 |
|
| 475 |
return [ |
| 476 |
'start_date' => $start, |
| 477 |
'end_date' => $end, |
| 478 |
]; |
| 479 |
} |
| 480 |
} |
| 481 |
|