PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Tools / BookingTools.php

BookingTools.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Tools/BookingTools.php

481 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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