PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 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 All 34 releases
← All changes | app/Modules/MCP/Tools/BookingTools.php +36 -73 2.4.0 → 2.5.0 View file →
@@ -12,29 +12,20 @@
12 12
13 13 defined('ABSPATH') || exit;
14 14
15 15 /**
16 - * Reading bookings — the surface an agent spends most of its calls on.
16 + * Read-only booking tools. Attendees, hosts etc. are `include` values on
17 + * get-booking rather than separate tools, to save schema tokens.
17 18 *
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.
19 + * Scoping happens in the query, not the response, so totals and pagination
20 + * never leak rows the caller can't see.
27 21 */
28 22 class BookingTools
29 23 {
30 24 const DEFAULT_PER_PAGE = 20;
31 25
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'];
26 + // `include` values for get-booking. Each costs a query, so none are default.
27 + const INCLUDABLE = ['custom_fields', 'attendees', 'hosts', 'activities', 'notes'];
37 28
38 29 public static function definitions()
39 30 {
40 31 return [
@@ -113,9 +104,9 @@
113 104 ],
114 105
115 106 'fluent-booking/get-booking' => [
116 107 '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'),
108 + '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. include notes is a Pro section: host notes, oldest first.', 'fluent-booking'),
118 109 'input_schema' => [
119 110 'type' => 'object',
120 111 'properties' => [
121 112 'booking_id' => ['type' => 'integer'],
@@ -162,17 +153,13 @@
162 153 if (is_wp_error($query)) {
163 154 return $query;
164 155 }
165 156
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.
157 + // Collapse group bookings by default, like the admin list, so counts match.
170 158 $grouped = (bool) Arr::get($params, 'group_bookings', true);
171 159
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.
160 + // Not when searching: GROUP BY keeps an arbitrary row per group and
161 + // could hide the attendee that actually matched.
175 162 $searchCollapsed = $grouped && trim((string) Arr::get($params, 'search', '')) !== '';
176 163
177 164 if ($searchCollapsed) {
178 165 $grouped = false;
@@ -180,13 +167,10 @@
180 167
181 168 $perPage = MCPHelper::perPage(Arr::get($params, 'per_page'), self::DEFAULT_PER_PAGE);
182 169 $page = max(1, absint(Arr::get($params, 'page', 1)));
183 170
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().
171 + // Count before groupBy: COUNT() on a grouped query returns the first
172 + // group's size. Mirrors SchedulesController::addCountsForFirstPage().
189 173 $total = $grouped
190 174 ? (clone $query)->withoutEagerLoads()->distinct('group_id')->count('group_id')
191 175 : (clone $query)->withoutEagerLoads()->count();
192 176
@@ -198,11 +182,10 @@
198 182 ->skip(($page - 1) * $perPage)
199 183 ->take($perPage)
200 184 ->get();
201 185
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.
186 + // Unmasked emails need read-all-bookings. Without it, downgrade and
187 + // flag pii_masked rather than erroring.
205 188 $wantsPii = (bool) Arr::get($params, 'include_pii', false);
206 189 $includePii = $wantsPii && $seesAll;
207 190
208 191 $rows = [];
@@ -277,10 +260,18 @@
277 260
278 261 $include = Arr::get($params, 'include', []);
279 262 $include = is_array($include) ? array_intersect($include, self::INCLUDABLE) : [];
280 263
264 + $bookingData = apply_filters(
265 + 'fluent_booking/mcp_booking_full',
266 + BookingProjector::full($booking, $timezone, $include),
267 + $booking,
268 + $timezone,
269 + $include
270 + );
271 +
281 272 return MCPHelper::success(
282 - BookingProjector::full($booking, $timezone, $include),
273 + $bookingData,
283 274 [
284 275 'timezone' => $timezone,
285 276 'scope' => PermissionGate::currentScope(),
286 277 ]
@@ -287,23 +278,12 @@
287 278 );
288 279 }
289 280
290 281 /**
291 - * True when the caller may read this specific booking.
282 + * Whether the caller may read this booking. Must match the scope
283 + * list-bookings uses (Booking::whereHostAccess()), so a booking hidden from
284 + * the list can't be opened by guessing its id.
292 285 *
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 286 * @param Booking $booking
307 287 * @return bool
308 288 */
309 289 private static function canReadBooking(Booking $booking)
@@ -321,10 +301,10 @@
321 301 if (in_array($userId, array_map('intval', (array) $booking->getHostIds()), true)) {
322 302 return true;
323 303 }
324 304
325 - // Calendar ownership, matching whereHostAccess()'s first branch. Not
326 - // canReadCalendar(): that also admits shared calendars.
305 + // Calendar ownership, as in whereHostAccess(). Not canReadCalendar():
306 + // it admits any team member on a shared calendar.
327 307 return (bool) Calendar::where('id', $booking->calendar_id)
328 308 ->where('user_id', $userId)
329 309 ->exists();
330 310 }
@@ -329,14 +309,11 @@
329 309 ->exists();
330 310 }
331 311
332 312 /**
333 - * Compose the list query from the filters, scoped to what the caller may see.
313 + * Build the list query, scoped to what the caller may see. Filters reuse
314 + * the Booking scopes the admin list uses, so the two agree.
334 315 *
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 316 * @param array $params
340 317 * @param bool $seesAll
341 318 * @param string $timezone resolved IANA identifier the from/to dates are read in
342 319 * @return object|\WP_Error
@@ -388,13 +365,9 @@
388 365 if ($range) {
389 366 $query->applyDateRangeFilter($range);
390 367 }
391 368
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.
369 + // Before the status branch below, which returns early.
397 370 $search = sanitize_text_field((string) Arr::get($params, 'search', ''));
398 371
399 372 if ($search) {
400 373 $query->searchBy($search);
@@ -399,12 +372,10 @@
399 372 if ($search) {
400 373 $query->searchBy($search);
401 374 }
402 375
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.
376 + // An explicit status list replaces the period bucket. ANDing the two
377 + // can contradict and silently return nothing.
407 378 $statuses = Arr::get($params, 'status', []);
408 379 $statuses = is_array($statuses) ? array_filter(array_map('sanitize_text_field', $statuses)) : [];
409 380
410 381 if ($statuses) {
@@ -426,18 +397,11 @@
426 397 return $query;
427 398 }
428 399
429 400 /**
430 - * Translate the caller's from/to dates into the UTC window they mean.
401 + * Convert from/to dates into a UTC window. The dates are local calendar
402 + * days in $timezone, while `start_time` is stored in UTC.
431 403 *
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 404 * @param array $params
441 405 * @param string $timezone resolved IANA identifier
442 406 * @return array|\WP_Error [] when unbounded
443 407 */
@@ -459,10 +423,9 @@
459 423 );
460 424 }
461 425 }
462 426
463 - // An open-ended bound is a usable question; fill the other end rather
464 - // than rejecting it.
427 + // Open-ended ranges are allowed.
465 428 $start = $from ? MCPHelper::dayBoundaryToUtc($from, $timezone, false) : '1970-01-01 00:00:00';
466 429 $end = $to ? MCPHelper::dayBoundaryToUtc($to, $timezone, true) : '2999-12-31 23:59:59';
467 430
468 431 if ($end < $start) {