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
fluent-booking / app / Modules / MCP / Tools / BookingTools.php

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

444 lines 16.6 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 * Read-only booking tools. Attendees, hosts etc. are `include` values on
17 * get-booking rather than separate tools, to save schema tokens.
18 *
19 * Scoping happens in the query, not the response, so totals and pagination
20 * never leak rows the caller can't see.
21 */
22 class BookingTools
23 {
24 const DEFAULT_PER_PAGE = 20;
25
26 // `include` values for get-booking. Each costs a query, so none are default.
27 const INCLUDABLE = ['custom_fields', 'attendees', 'hosts', 'activities', 'notes'];
28
29 public static function definitions()
30 {
31 return [
32 'fluent-booking/list-bookings' => [
33 'label' => __('List bookings', 'fluent-booking'),
34 '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'),
35 'input_schema' => [
36 'type' => 'object',
37 'properties' => [
38 'period' => [
39 'type' => 'string',
40 'description' => __('Computed bucket. Defaults to upcoming.', 'fluent-booking'),
41 'enum' => ContextTools::bookingPeriods(),
42 ],
43 'status' => [
44 'type' => 'array',
45 'description' => __('Filter by raw status values instead of a period bucket.', 'fluent-booking'),
46 'items' => [
47 'type' => 'string',
48 'enum' => ContextTools::bookingStatuses(),
49 ],
50 ],
51 'calendar_id' => ['type' => 'integer'],
52 'event_id' => ['type' => 'integer'],
53 'event_type' => [
54 'type' => 'string',
55 'enum' => ContextTools::eventTypes(),
56 ],
57 'host_id' => ['type' => 'integer'],
58 'email' => [
59 'type' => 'string',
60 'description' => __('Exact attendee email match.', 'fluent-booking'),
61 ],
62 'search' => [
63 'type' => 'string',
64 'description' => __('Free-text search across attendee name, email and phone.', 'fluent-booking'),
65 ],
66 'from' => [
67 'type' => 'string',
68 'description' => __('Start of the booking date range, Y-m-d.', 'fluent-booking'),
69 ],
70 'to' => [
71 'type' => 'string',
72 'description' => __('End of the booking date range, Y-m-d.', 'fluent-booking'),
73 ],
74 'timezone' => [
75 'type' => 'string',
76 'description' => __('IANA timezone for the *_local times in the response. Defaults to the site timezone.', 'fluent-booking'),
77 ],
78 'group_bookings' => [
79 'type' => 'boolean',
80 'description' => __('Collapse group bookings to one row, matching the admin list. Default true.', 'fluent-booking'),
81 ],
82 'include_pii' => [
83 'type' => 'boolean',
84 'description' => __('Return unmasked attendee emails. Requires read access to all bookings.', 'fluent-booking'),
85 ],
86 'page' => ['type' => 'integer'],
87 'per_page' => [
88 'type' => 'integer',
89 'description' => sprintf(
90 /* translators: %1$d: default page size, %2$d: maximum page size */
91 __('Default %1$d, maximum %2$d.', 'fluent-booking'),
92 self::DEFAULT_PER_PAGE,
93 MCPHelper::MAX_PER_PAGE
94 ),
95 ],
96 ],
97 ],
98 'annotations' => [
99 'title' => __('List bookings', 'fluent-booking'),
100 'readonly' => true,
101 ],
102 'permission_callback' => [PermissionGate::class, 'readGate'],
103 'execute_callback' => [self::class, 'listBookings'],
104 ],
105
106 'fluent-booking/get-booking' => [
107 'label' => __('Get booking', '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'),
109 'input_schema' => [
110 'type' => 'object',
111 'properties' => [
112 'booking_id' => ['type' => 'integer'],
113 'hash' => [
114 'type' => 'string',
115 'description' => __('The booking hash, as an alternative to booking_id.', 'fluent-booking'),
116 ],
117 'include' => [
118 'type' => 'array',
119 'description' => __('Extra sections to load. Each one costs an additional query.', 'fluent-booking'),
120 'items' => [
121 'type' => 'string',
122 'enum' => self::INCLUDABLE,
123 ],
124 ],
125 'timezone' => [
126 'type' => 'string',
127 'description' => __('IANA timezone for the *_local times in the response.', 'fluent-booking'),
128 ],
129 ],
130 ],
131 'annotations' => [
132 'title' => __('Get booking', 'fluent-booking'),
133 'readonly' => true,
134 ],
135 'permission_callback' => [PermissionGate::class, 'readGate'],
136 'execute_callback' => [self::class, 'getBooking'],
137 ],
138 ];
139 }
140
141 /**
142 * @param array $params
143 * @return array|\WP_Error
144 */
145 public static function listBookings($params = [])
146 {
147 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
148
149 $seesAll = PermissionGate::canSeeAllBookings();
150
151 $query = self::buildQuery($params, $seesAll, $timezone);
152
153 if (is_wp_error($query)) {
154 return $query;
155 }
156
157 // Collapse group bookings by default, like the admin list, so counts match.
158 $grouped = (bool) Arr::get($params, 'group_bookings', true);
159
160 // Not when searching: GROUP BY keeps an arbitrary row per group and
161 // could hide the attendee that actually matched.
162 $searchCollapsed = $grouped && trim((string) Arr::get($params, 'search', '')) !== '';
163
164 if ($searchCollapsed) {
165 $grouped = false;
166 }
167
168 $perPage = MCPHelper::perPage(Arr::get($params, 'per_page'), self::DEFAULT_PER_PAGE);
169 $page = max(1, absint(Arr::get($params, 'page', 1)));
170
171 // Count before groupBy: COUNT() on a grouped query returns the first
172 // group's size. Mirrors SchedulesController::addCountsForFirstPage().
173 $total = $grouped
174 ? (clone $query)->withoutEagerLoads()->distinct('group_id')->count('group_id')
175 : (clone $query)->withoutEagerLoads()->count();
176
177 if ($grouped) {
178 $query->groupBy('group_id');
179 }
180
181 $bookings = $query->with(BookingProjector::rowRelations())
182 ->skip(($page - 1) * $perPage)
183 ->take($perPage)
184 ->get();
185
186 // Unmasked emails need read-all-bookings. Without it, downgrade and
187 // flag pii_masked rather than erroring.
188 $wantsPii = (bool) Arr::get($params, 'include_pii', false);
189 $includePii = $wantsPii && $seesAll;
190
191 $rows = [];
192
193 foreach ($bookings as $booking) {
194 $rows[] = BookingProjector::row($booking, $timezone, $includePii);
195 }
196
197 $meta = array_merge(
198 MCPHelper::paginationMeta($total, $page, $perPage),
199 [
200 'timezone' => $timezone,
201 'scope' => PermissionGate::currentScope(),
202 ]
203 );
204
205 if ($wantsPii && !$includePii) {
206 $meta['pii_masked'] = true;
207 }
208
209 if ($searchCollapsed) {
210 $meta['group_bookings'] = false;
211 $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');
212 }
213
214 return MCPHelper::success($rows, $meta);
215 }
216
217 /**
218 * @param array $params
219 * @return array|\WP_Error
220 */
221 public static function getBooking($params = [])
222 {
223 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
224
225 $bookingId = absint(Arr::get($params, 'booking_id'));
226 $hash = sanitize_text_field((string) Arr::get($params, 'hash', ''));
227
228 if (!$bookingId && !$hash) {
229 return MCPHelper::error(
230 'missing_identifier',
231 __('Pass either booking_id or hash.', 'fluent-booking')
232 );
233 }
234
235 $query = Booking::query();
236
237 if ($bookingId) {
238 $query->where('id', $bookingId);
239 } else {
240 $query->where('hash', $hash);
241 }
242
243 $booking = $query->with(BookingProjector::rowRelations())->first();
244
245 if (!$booking) {
246 return MCPHelper::error(
247 'booking_not_found',
248 __('No booking matched that identifier.', 'fluent-booking')
249 );
250 }
251
252 // Ownership, not just capability: a host with only their own access may
253 // hold a valid booking id belonging to somebody else.
254 if (!self::canReadBooking($booking)) {
255 return MCPHelper::error(
256 'permission_denied',
257 __('You do not have access to this booking.', 'fluent-booking')
258 );
259 }
260
261 $include = Arr::get($params, 'include', []);
262 $include = is_array($include) ? array_intersect($include, self::INCLUDABLE) : [];
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
272 return MCPHelper::success(
273 $bookingData,
274 [
275 'timezone' => $timezone,
276 'scope' => PermissionGate::currentScope(),
277 ]
278 );
279 }
280
281 /**
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.
285 *
286 * @param Booking $booking
287 * @return bool
288 */
289 private static function canReadBooking(Booking $booking)
290 {
291 if (PermissionGate::canSeeAllBookings()) {
292 return true;
293 }
294
295 $userId = (int) get_current_user_id();
296
297 if ((int) $booking->host_user_id === $userId) {
298 return true;
299 }
300
301 if (in_array($userId, array_map('intval', (array) $booking->getHostIds()), true)) {
302 return true;
303 }
304
305 // Calendar ownership, as in whereHostAccess(). Not canReadCalendar():
306 // it admits any team member on a shared calendar.
307 return (bool) Calendar::where('id', $booking->calendar_id)
308 ->where('user_id', $userId)
309 ->exists();
310 }
311
312 /**
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.
315 *
316 * @param array $params
317 * @param bool $seesAll
318 * @param string $timezone resolved IANA identifier the from/to dates are read in
319 * @return object|\WP_Error
320 */
321 private static function buildQuery($params, $seesAll, $timezone)
322 {
323 $query = Booking::query();
324
325 if (!$seesAll) {
326 $query->whereHostAccess(get_current_user_id());
327 }
328
329 $hostId = absint(Arr::get($params, 'host_id'));
330
331 if ($hostId) {
332 $query->where('host_user_id', $hostId);
333 }
334
335 $calendarId = absint(Arr::get($params, 'calendar_id'));
336
337 if ($calendarId) {
338 $query->where('calendar_id', $calendarId);
339 }
340
341 $eventId = absint(Arr::get($params, 'event_id'));
342
343 if ($eventId) {
344 $query->where('event_id', $eventId);
345 }
346
347 $eventType = sanitize_text_field((string) Arr::get($params, 'event_type', ''));
348
349 if ($eventType) {
350 $query->where('event_type', $eventType);
351 }
352
353 $email = sanitize_email((string) Arr::get($params, 'email', ''));
354
355 if ($email && is_email($email)) {
356 $query->where('email', $email);
357 }
358
359 $range = self::dateRange($params, $timezone);
360
361 if (is_wp_error($range)) {
362 return $range;
363 }
364
365 if ($range) {
366 $query->applyDateRangeFilter($range);
367 }
368
369 // Before the status branch below, which returns early.
370 $search = sanitize_text_field((string) Arr::get($params, 'search', ''));
371
372 if ($search) {
373 $query->searchBy($search);
374 }
375
376 // An explicit status list replaces the period bucket. ANDing the two
377 // can contradict and silently return nothing.
378 $statuses = Arr::get($params, 'status', []);
379 $statuses = is_array($statuses) ? array_filter(array_map('sanitize_text_field', $statuses)) : [];
380
381 if ($statuses) {
382 $query->whereIn('status', $statuses);
383 $query->orderBy('start_time', 'DESC');
384
385 return $query;
386 }
387
388 $period = sanitize_text_field((string) Arr::get($params, 'period', 'upcoming'));
389
390 if (!in_array($period, ContextTools::bookingPeriods(), true)) {
391 $period = 'upcoming';
392 }
393
394 $query->applyComputedStatus($period);
395 $query->applyBookingOrderByStatus($period);
396
397 return $query;
398 }
399
400 /**
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.
403 *
404 * @param array $params
405 * @param string $timezone resolved IANA identifier
406 * @return array|\WP_Error [] when unbounded
407 */
408 private static function dateRange($params, $timezone)
409 {
410 $from = sanitize_text_field((string) Arr::get($params, 'from', ''));
411 $to = sanitize_text_field((string) Arr::get($params, 'to', ''));
412
413 if (!$from && !$to) {
414 return [];
415 }
416
417 foreach ([$from, $to] as $date) {
418 if ($date && !MCPHelper::isRealDate($date)) {
419 return MCPHelper::error(
420 'invalid_date',
421 __('from and to must be real dates in Y-m-d form.', 'fluent-booking'),
422 ['received' => $date]
423 );
424 }
425 }
426
427 // Open-ended ranges are allowed.
428 $start = $from ? MCPHelper::dayBoundaryToUtc($from, $timezone, false) : '1970-01-01 00:00:00';
429 $end = $to ? MCPHelper::dayBoundaryToUtc($to, $timezone, true) : '2999-12-31 23:59:59';
430
431 if ($end < $start) {
432 return MCPHelper::error(
433 'invalid_range',
434 __('The end of the range is before its start.', 'fluent-booking')
435 );
436 }
437
438 return [
439 'start_date' => $start,
440 'end_date' => $end,
441 ];
442 }
443 }
444