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 / SchedulingTools.php

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

1,663 lines 66.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\Availability;
6 use FluentBooking\App\Models\Booking;
7 use FluentBooking\App\Models\Calendar;
8 use FluentBooking\App\Models\CalendarSlot;
9 use FluentBooking\App\Models\User;
10 use FluentBooking\App\Modules\MCP\Support\MCPHelper;
11 use FluentBooking\App\Modules\MCP\Support\PermissionGate;
12 use FluentBooking\App\Modules\MCP\Support\RestBridge;
13 use FluentBooking\App\Modules\MCP\Support\WriteGuard;
14 use FluentBooking\App\Services\AvailabilityService;
15 use FluentBooking\App\Services\PermissionManager;
16 use FluentBooking\App\Services\SanitizeService;
17 use FluentBooking\Framework\Support\Arr;
18
19 defined('ABSPATH') || exit;
20
21 /**
22 * The `scheduling` toolset: configuration work, off by default.
23 *
24 * Most sessions never touch setup, and these four tools cost about as much
25 * context as the core nine, so they sit behind their own switch.
26 *
27 * Writes go through RestBridge so the admin's validation runs. Reads are
28 * projected by hand, because admin responses are shaped for a UI.
29 *
30 * @see \FluentBooking\App\Modules\MCP\Support\RestBridge
31 */
32 class SchedulingTools
33 {
34 const REFERENCE_KINDS = ['hosts', 'calendars', 'location_providers', 'booking_fields', 'availability_schedules'];
35
36 /**
37 * Cap on any one reference list. A list that hits it says so and reports
38 * the real total.
39 */
40 const LIST_LIMIT = 100;
41
42 public static function definitions()
43 {
44 return [
45 'fluent-booking/get-availability' => [
46 'label' => __('Get availability', 'fluent-booking'),
47 'description' => __('Availability schedules — the weekly hours and date overrides an event type draws on. Lists them, or returns one in full when schedule_id is given. Hours are returned in the schedule\'s own timezone unless you ask for another.', 'fluent-booking'),
48 'input_schema' => [
49 'type' => 'object',
50 'properties' => [
51 'schedule_id' => [
52 'type' => 'integer',
53 'description' => __('Return this one schedule in full instead of the list.', 'fluent-booking'),
54 ],
55 'host_id' => [
56 'type' => 'integer',
57 'description' => __('Only schedules belonging to this host.', 'fluent-booking'),
58 ],
59 'timezone' => [
60 'type' => 'string',
61 'description' => __('Express the weekly hours in this IANA zone. Defaults to each schedule\'s own.', 'fluent-booking'),
62 ],
63 ],
64 ],
65 'annotations' => [
66 'title' => __('Get availability', 'fluent-booking'),
67 'readonly' => true,
68 ],
69 'permission_callback' => [PermissionGate::class, 'readGate'],
70 'execute_callback' => [self::class, 'getAvailability'],
71 ],
72
73 'fluent-booking/manage-availability' => [
74 'label' => __('Manage availability', 'fluent-booking'),
75 'description' => __('Create, rename, edit, clone, set as default or delete an availability schedule. dry_run previews any action and changes nothing. update replaces the whole weekly grid and delete removes the schedule, so both need a confirm_token from a dry run; delete also refuses a schedule still in use.', 'fluent-booking'),
76 'input_schema' => [
77 'type' => 'object',
78 'properties' => [
79 'action' => [
80 'type' => 'string',
81 'enum' => ['create', 'update', 'rename', 'clone', 'set_default', 'delete'],
82 ],
83 'schedule_id' => [
84 'type' => 'integer',
85 'description' => __('Required for everything except create.', 'fluent-booking'),
86 ],
87 'title' => [
88 'type' => 'string',
89 'description' => __('Required for create and rename.', 'fluent-booking'),
90 ],
91 'timezone' => [
92 'type' => 'string',
93 'description' => __('IANA zone the weekly hours and overrides are expressed in. Defaults to the schedule\'s own.', 'fluent-booking'),
94 ],
95 'weekly_schedules' => [
96 'type' => 'object',
97 'description' => __('update only. Keyed sun..sat, each {enabled, slots:[{start,end}]} in 24h HH:MM. Replaces the whole grid.', 'fluent-booking'),
98 ],
99 'date_overrides' => [
100 'type' => 'object',
101 'description' => __('update only. Keyed by date, each Y-m-d mapping to [{start,end}] in 24h HH:MM. Replaces all existing overrides; dates in the past are dropped.', 'fluent-booking'),
102 ],
103 'dry_run' => [
104 'type' => 'boolean',
105 'description' => __('Preview any action without changing anything.', 'fluent-booking'),
106 ],
107 'confirm_token' => [
108 'type' => 'string',
109 'description' => __('From a dry run. Required for update and delete.', 'fluent-booking'),
110 ],
111 'idempotency_key' => [
112 'type' => 'string',
113 'description' => __('Your own id for this change. Retrying with the same key replays the first result instead of repeating the change.', 'fluent-booking'),
114 ],
115 ],
116 'required' => ['action'],
117 ],
118 'annotations' => [
119 'title' => __('Manage availability', 'fluent-booking'),
120 'readonly' => false,
121 'destructive' => true,
122 ],
123 'permission_callback' => [PermissionGate::class, 'scheduleWriteGate'],
124 'execute_callback' => [self::class, 'manageAvailability'],
125 ],
126
127 'fluent-booking/manage-event-type' => [
128 'label' => __('Manage event type', 'fluent-booking'),
129 'description' => __('Create, edit, duplicate, activate, deactivate or delete an event type. dry_run previews any action and changes nothing. Edits are sectioned the same way the admin saves them, so send only the section you are changing; keys you leave out keep their current values, except weekly_schedules and date_overrides, which are replaced whole. update on the availability and limits sections needs a confirm_token, as does delete, which also refuses while ANY booking exists — past or cancelled included, since all of them are deleted with the event type — unless you pass force.', 'fluent-booking'),
130 'input_schema' => [
131 'type' => 'object',
132 'properties' => [
133 'action' => [
134 'type' => 'string',
135 'enum' => ['create', 'update', 'duplicate', 'activate', 'deactivate', 'delete'],
136 ],
137 'event_id' => [
138 'type' => 'integer',
139 'description' => __('Required for everything except create.', 'fluent-booking'),
140 ],
141 'calendar_id' => [
142 'type' => 'integer',
143 'description' => __('Required for create; the calendar the event type belongs to.', 'fluent-booking'),
144 ],
145 'section' => [
146 'type' => 'string',
147 'description' => __('update only. Which group of settings the fields belong to.', 'fluent-booking'),
148 'enum' => ['details', 'availability', 'limits', 'booking_fields'],
149 ],
150 'fields' => [
151 'type' => 'object',
152 'description' => __('The settings to write. For create: title, duration, event_type, status, location_settings. For update: whatever that section accepts — call get-event-types with event_id to see the current values first.', 'fluent-booking'),
153 ],
154 'force' => [
155 'type' => 'boolean',
156 'description' => __('delete only. Delete even though bookings exist. Every booking on the event type goes with it — past, cancelled and upcoming — plus their activity history and payment records.', 'fluent-booking'),
157 ],
158 'dry_run' => [
159 'type' => 'boolean',
160 'description' => __('Preview any action without changing anything.', 'fluent-booking'),
161 ],
162 'confirm_token' => [
163 'type' => 'string',
164 'description' => __('From a dry run. Required for delete and for update on the availability and limits sections.', 'fluent-booking'),
165 ],
166 'idempotency_key' => [
167 'type' => 'string',
168 'description' => __('Your own id for this change. Retrying with the same key replays the first result instead of repeating the change.', 'fluent-booking'),
169 ],
170 ],
171 'required' => ['action'],
172 ],
173 'annotations' => [
174 'title' => __('Manage event type', 'fluent-booking'),
175 'readonly' => false,
176 'destructive' => true,
177 ],
178 'permission_callback' => [PermissionGate::class, 'scheduleWriteGate'],
179 'execute_callback' => [self::class, 'manageEventType'],
180 ],
181
182 'fluent-booking/list-reference-data' => [
183 'label' => __('List reference data', 'fluent-booking'),
184 'description' => __('The lookup lists get-booking-context leaves out on larger sites, plus the ones only configuration work needs: hosts, calendars, location providers, an event type\'s booking fields, and availability schedules. Ask only for the kinds you need.', 'fluent-booking'),
185 'input_schema' => [
186 'type' => 'object',
187 'properties' => [
188 'kinds' => [
189 'type' => 'array',
190 'items' => [
191 'type' => 'string',
192 'enum' => self::REFERENCE_KINDS,
193 ],
194 ],
195 'event_id' => [
196 'type' => 'integer',
197 'description' => __('Required for booking_fields; they are per event type.', 'fluent-booking'),
198 ],
199 ],
200 'required' => ['kinds'],
201 ],
202 'annotations' => [
203 'title' => __('List reference data', 'fluent-booking'),
204 'readonly' => true,
205 ],
206 'permission_callback' => [PermissionGate::class, 'readGate'],
207 'execute_callback' => [self::class, 'listReferenceData'],
208 ],
209 ];
210 }
211
212 /**
213 * @param array $params
214 * @return array|\WP_Error
215 */
216 public static function getAvailability($params = [])
217 {
218 $scheduleId = absint(Arr::get($params, 'schedule_id'));
219 $timezone = sanitize_text_field(Arr::get($params, 'timezone', ''));
220
221 if ($scheduleId) {
222 $schedule = Availability::find($scheduleId);
223
224 if (!$schedule) {
225 return MCPHelper::error('not_found', __('No availability schedule with that id.', 'fluent-booking'));
226 }
227
228 if (!self::canReadSchedule($schedule)) {
229 return MCPHelper::error('permission_denied', __('You do not have permission to read this schedule.', 'fluent-booking'));
230 }
231
232 $projected = self::projectSchedule($schedule, $timezone, true);
233
234 // Report the zone the row resolved, not the requested one, which
235 // may have been invalid.
236 return MCPHelper::success($projected, ['timezone' => $projected['timezone']]);
237 }
238
239 $query = Availability::orderBy('id', 'desc');
240
241 if (!PermissionManager::userCan(['manage_all_data', 'read_and_use_other_availabilities', 'manage_other_availabilities'])) {
242 $query->where('object_id', get_current_user_id());
243 } elseif ($hostId = absint(Arr::get($params, 'host_id'))) {
244 $query->where('object_id', $hostId);
245 }
246
247 $total = (clone $query)->count();
248
249 $schedules = [];
250
251 // Fetch one past the cap so a truncated list can say so.
252 $rows = [];
253
254 foreach ($query->limit(self::LIST_LIMIT + 1)->get() as $schedule) {
255 if (count($rows) >= self::LIST_LIMIT) {
256 break;
257 }
258
259 $rows[] = $schedule;
260 }
261
262 // One grouped query for the page rather than a usage count per row.
263 $usage = self::usageCounts(array_map(function ($schedule) {
264 return (int) $schedule->id;
265 }, $rows));
266
267 foreach ($rows as $schedule) {
268 $id = (int) $schedule->id;
269
270 $schedules[] = self::projectSchedule($schedule, $timezone, false, isset($usage[$id]) ? $usage[$id] : 0);
271 }
272
273 $meta = [
274 'count' => count($schedules),
275 'total' => (int) $total,
276 'scope' => PermissionGate::currentScope(),
277 ];
278
279 if ($total > count($schedules)) {
280 $meta['truncated'] = true;
281 $meta['truncation_note'] = sprintf(
282 /* translators: 1: number returned, 2: number that exist */
283 __('Showing %1$d of %2$d schedules. Narrow with host_id.', 'fluent-booking'),
284 count($schedules),
285 (int) $total
286 );
287 }
288
289 return MCPHelper::success(
290 ['schedules' => $schedules],
291 $meta,
292 $schedules ? 'Call again with schedule_id for one schedule\'s weekly hours and date overrides.' : ''
293 );
294 }
295
296 /**
297 * @param array $params
298 * @return array|\WP_Error
299 */
300 public static function manageAvailability($params = [])
301 {
302 return self::deduped('fluent-booking/manage-availability', $params, function () use ($params) {
303 return self::manageAvailabilityAction($params);
304 }, function ($params) {
305 $scheduleId = absint(Arr::get($params, 'schedule_id'));
306
307 // A create has no id yet, so the title stands in for one.
308 return 'availability:' . ($scheduleId ?: 'new:' . md5(strtolower(trim((string) Arr::get($params, 'title', ''))))) . ':' . sanitize_text_field(Arr::get($params, 'action', ''));
309 });
310 }
311
312 /**
313 * @param array $params
314 * @return array|\WP_Error
315 */
316 private static function manageAvailabilityAction($params)
317 {
318 $action = sanitize_text_field(Arr::get($params, 'action', ''));
319 $dryRun = Arr::isTrue($params, 'dry_run');
320
321 if ($action === 'create') {
322 $title = sanitize_text_field(Arr::get($params, 'title', ''));
323
324 if (!$title) {
325 return MCPHelper::error('missing_title', __('create needs a title.', 'fluent-booking'));
326 }
327
328 // create always lays down the stock Mon-Fri grid. Refuse hours
329 // rather than report success on a schedule without them.
330 if (Arr::get($params, 'weekly_schedules') || Arr::get($params, 'date_overrides')) {
331 return MCPHelper::error(
332 'hours_not_accepted_on_create',
333 __('create makes a schedule with the default weekly hours; it cannot set them. Create it first, then call update with weekly_schedules to replace the grid.', 'fluent-booking')
334 );
335 }
336
337 if ($dryRun) {
338 return self::additivePreview('create', [
339 'title' => $title,
340 'timezone' => sanitize_text_field(Arr::get($params, 'timezone', '')) ?: MCPHelper::resolveTimezone(''),
341 ]);
342 }
343
344 return self::bridged('POST', '/availability', [
345 'title' => $title,
346 'timezone' => sanitize_text_field(Arr::get($params, 'timezone', '')),
347 ]);
348 }
349
350 $scheduleId = absint(Arr::get($params, 'schedule_id'));
351
352 if (!$scheduleId) {
353 return MCPHelper::error('missing_schedule_id', __('schedule_id is required. Call get-availability to find one.', 'fluent-booking'));
354 }
355
356 $schedule = Availability::find($scheduleId);
357
358 if (!$schedule) {
359 return MCPHelper::error('not_found', __('No availability schedule with that id.', 'fluent-booking'));
360 }
361
362 if (!self::canWriteSchedule($schedule)) {
363 return MCPHelper::error(
364 'permission_denied',
365 __('You do not have permission to change this schedule.', 'fluent-booking'),
366 ['schedule_id' => $scheduleId]
367 );
368 }
369
370 if ($action === 'delete') {
371 return self::deleteAvailability($schedule, $params);
372 }
373
374 if ($action === 'clone') {
375 if ($dryRun) {
376 return self::additivePreview('clone', ['source' => self::scheduleSummary($schedule)]);
377 }
378
379 return self::bridged('POST', '/availability/' . $scheduleId . '/clone');
380 }
381
382 if ($action === 'set_default') {
383 if ($dryRun) {
384 return self::reversiblePreview('set_default', [
385 'schedule' => self::scheduleSummary($schedule),
386 'effect' => __('This schedule becomes the default for new event types. The current default stops being the default; nothing else changes.', 'fluent-booking'),
387 ]);
388 }
389
390 return self::bridged('POST', '/availability/' . $scheduleId . '/update-status', ['default' => true]);
391 }
392
393 if ($action === 'rename') {
394 $title = sanitize_text_field(Arr::get($params, 'title', ''));
395
396 if (!$title) {
397 return MCPHelper::error('missing_title', __('rename needs a title.', 'fluent-booking'));
398 }
399
400 if ($dryRun) {
401 return self::reversiblePreview('rename', [
402 'schedule' => self::scheduleSummary($schedule),
403 'from' => $schedule->key,
404 'to' => $title,
405 ]);
406 }
407
408 return self::bridged('POST', '/availability/' . $scheduleId . '/update-title', ['title' => $title]);
409 }
410
411 if ($action === 'update') {
412 $timezone = sanitize_text_field(Arr::get($params, 'timezone', '')) ?: Arr::get($schedule, 'value.timezone', 'UTC');
413
414 $weekly = Arr::get($params, 'weekly_schedules');
415
416 if (!is_array($weekly) || !$weekly) {
417 return MCPHelper::error(
418 'missing_weekly_schedules',
419 __('update replaces the whole weekly grid, so weekly_schedules is required. Read the schedule with get-availability first and send it back changed.', 'fluent-booking')
420 );
421 }
422
423 // update replaces the whole grid with no merge and no undo, so it
424 // is gated like delete: preview, then a token bound to these hours.
425 $tool = 'fluent-booking/manage-availability';
426 $entityKey = 'availability:' . $scheduleId . ':update';
427 $fingerprint = self::scheduleFingerprint($schedule);
428 $digest = WriteGuard::paramsDigest($params);
429
430 if ($dryRun) {
431 $current = AvailabilityService::getFormattedSchedule($schedule);
432
433 return MCPHelper::success(WriteGuard::preview($tool, $entityKey, $fingerprint, [
434 'action' => 'update',
435 'schedule' => self::scheduleSummary($schedule),
436 'replacing' => Arr::get($current, 'settings.weekly_schedules', []),
437 'with' => $weekly,
438 'timezone' => $timezone,
439 'date_override_count' => count((array) Arr::get($params, 'date_overrides', [])),
440 'note' => __('The whole weekly grid and every date override are replaced by what you send. Days you omit become unavailable.', 'fluent-booking'),
441 ], $digest), [], WriteGuard::CONFIRM_NEXT_STEP);
442 }
443
444 $confirmed = WriteGuard::confirm($tool, $entityKey, $fingerprint, Arr::get($params, 'confirm_token', ''), $digest);
445
446 if (is_wp_error($confirmed)) {
447 return $confirmed;
448 }
449
450 return self::bridged('POST', '/availability/' . $scheduleId, [
451 'schedule' => [
452 'settings' => [
453 'timezone' => $timezone,
454 'weekly_schedules' => $weekly,
455 'date_overrides' => (array) Arr::get($params, 'date_overrides', []),
456 ],
457 ],
458 ]);
459 }
460
461 return MCPHelper::error('unsupported_action', __('Unknown action.', 'fluent-booking'));
462 }
463
464 /**
465 * Preview shape for an action that only adds something.
466 *
467 * dry_run must change nothing for every action, not just the token-gated
468 * ones, or an agent's cautious preview would be the write. Additive and
469 * reversible actions answer a dry run without a token.
470 *
471 * @param string $action
472 * @param array $preview
473 * @return array
474 */
475 private static function additivePreview($action, $preview)
476 {
477 return MCPHelper::success(
478 [
479 'dry_run' => true,
480 'preview' => ['action' => $action] + $preview,
481 ],
482 [],
483 'Nothing exists yet and nothing was changed. Call again without dry_run to create it; no confirm_token is needed.'
484 );
485 }
486
487 /**
488 * @param string $action
489 * @param array $preview
490 * @return array
491 */
492 private static function reversiblePreview($action, $preview)
493 {
494 return MCPHelper::success(
495 [
496 'dry_run' => true,
497 'preview' => ['action' => $action] + $preview,
498 ],
499 [],
500 'Nothing was changed. This action is reversible — call again without dry_run to apply it; no confirm_token is needed.'
501 );
502 }
503
504 /**
505 * @return array
506 */
507 private static function scheduleSummary(Availability $schedule)
508 {
509 return [
510 'id' => (int) $schedule->id,
511 'title' => $schedule->key,
512 'host_id' => (int) $schedule->object_id,
513 'usage_count' => AvailabilityService::getAvailabilityUsageCount($schedule->id),
514 ];
515 }
516
517 /**
518 * @return string
519 */
520 private static function eventFingerprint(CalendarSlot $event)
521 {
522 $updatedAt = $event->updated_at instanceof \DateTimeInterface
523 ? $event->updated_at->format('Y-m-d H:i:s')
524 : $event->updated_at;
525
526 return implode('|', [$event->id, $event->status, $updatedAt]);
527 }
528
529 /**
530 * @return string
531 */
532 private static function scheduleFingerprint(Availability $schedule)
533 {
534 $updatedAt = $schedule->updated_at instanceof \DateTimeInterface
535 ? $schedule->updated_at->format('Y-m-d H:i:s')
536 : $schedule->updated_at;
537
538 return implode('|', [$schedule->id, $schedule->key, $updatedAt]);
539 }
540
541 /**
542 * @return array|\WP_Error
543 */
544 private static function deleteAvailability(Availability $schedule, $params)
545 {
546 $usage = AvailabilityService::getAvailabilityUsageCount($schedule->id);
547
548 if ($usage) {
549 return MCPHelper::error(
550 'schedule_in_use',
551 /* translators: %d: number of event types using the schedule */
552 sprintf(__('%d event types use this schedule. Point them at another one before deleting it.', 'fluent-booking'), $usage),
553 ['usage_count' => $usage]
554 );
555 }
556
557 $tool = 'fluent-booking/manage-availability';
558 $entityKey = 'availability:' . $schedule->id . ':delete';
559 $fingerprint = self::scheduleFingerprint($schedule);
560 $digest = WriteGuard::paramsDigest($params);
561
562 if (Arr::isTrue($params, 'dry_run')) {
563 return MCPHelper::success(WriteGuard::preview($tool, $entityKey, $fingerprint, [
564 'action' => 'delete',
565 'schedule' => self::scheduleSummary($schedule),
566 'note' => __('Nothing currently uses this schedule.', 'fluent-booking'),
567 ], $digest), [], WriteGuard::CONFIRM_NEXT_STEP);
568 }
569
570 $confirmed = WriteGuard::confirm($tool, $entityKey, $fingerprint, Arr::get($params, 'confirm_token', ''), $digest);
571
572 if (is_wp_error($confirmed)) {
573 return $confirmed;
574 }
575
576 return self::bridged('DELETE', '/availability/' . $schedule->id);
577 }
578
579 /**
580 * @param array $params
581 * @return array|\WP_Error
582 */
583 public static function manageEventType($params = [])
584 {
585 return self::deduped('fluent-booking/manage-event-type', $params, function () use ($params) {
586 return self::manageEventTypeAction($params);
587 }, function ($params) {
588 $eventId = absint(Arr::get($params, 'event_id'));
589 $action = sanitize_text_field(Arr::get($params, 'action', ''));
590
591 if ($eventId) {
592 return 'event:' . $eventId . ':' . $action . ':' . sanitize_text_field(Arr::get($params, 'section', ''));
593 }
594
595 $title = (string) Arr::get($params, 'fields.title', '');
596
597 return 'event:new:' . absint(Arr::get($params, 'calendar_id')) . ':' . md5(strtolower(trim($title)));
598 });
599 }
600
601 /**
602 * Idempotency for the scheduling writes, so a retried create or clone
603 * doesn't leave two live bookable records.
604 *
605 * @param string $tool
606 * @param array $params
607 * @param callable $fn
608 * @param callable $entityKey
609 *
610 * @return mixed
611 */
612 private static function deduped($tool, $params, callable $fn, callable $entityKey)
613 {
614 $key = (string) Arr::get($params, 'idempotency_key', '');
615
616 // Recording a dry run would replay the preview in place of the real write.
617 if (!$key || Arr::isTrue($params, 'dry_run')) {
618 return $fn();
619 }
620
621 return WriteGuard::idempotent(
622 $tool,
623 call_user_func($entityKey, $params),
624 $key,
625 $fn,
626 WriteGuard::paramsDigest($params)
627 );
628 }
629
630 /**
631 * @param array $params
632 * @return array|\WP_Error
633 */
634 private static function manageEventTypeAction($params)
635 {
636 $action = sanitize_text_field(Arr::get($params, 'action', ''));
637 $fields = (array) Arr::get($params, 'fields', []);
638 $dryRun = Arr::isTrue($params, 'dry_run');
639
640 if ($action === 'create') {
641 $calendarId = absint(Arr::get($params, 'calendar_id'));
642
643 if (!$calendarId) {
644 return MCPHelper::error('missing_calendar_id', __('create needs calendar_id. Call list-reference-data with kinds:["calendars"].', 'fluent-booking'));
645 }
646
647 if (!PermissionManager::canWriteCalendar($calendarId)) {
648 return MCPHelper::error('permission_denied', __('You do not have permission to add event types to this calendar.', 'fluent-booking'));
649 }
650
651 $payload = self::createPayload($calendarId, $fields);
652
653 if (is_wp_error($payload)) {
654 return $payload;
655 }
656
657 if ($dryRun) {
658 return self::additivePreview('create', [
659 'calendar_id' => $calendarId,
660 'title' => Arr::get($payload, 'title', ''),
661 'duration' => (int) Arr::get($payload, 'duration', 0),
662 'event_type' => Arr::get($payload, 'event_type', ''),
663 'status' => Arr::get($payload, 'status', ''),
664 'locations' => array_values(array_filter(array_map(function ($location) {
665 return Arr::get($location, 'type');
666 }, (array) Arr::get($payload, 'location_settings', [])))),
667 ]);
668 }
669
670 return self::bridged('POST', '/calendars/' . $calendarId . '/events', $payload);
671 }
672
673 $eventId = absint(Arr::get($params, 'event_id'));
674
675 if (!$eventId) {
676 return MCPHelper::error('missing_event_id', __('event_id is required. Call get-event-types to find one.', 'fluent-booking'));
677 }
678
679 $event = CalendarSlot::find($eventId);
680
681 if (!$event) {
682 return MCPHelper::error('not_found', __('No event type with that id.', 'fluent-booking'));
683 }
684
685 // Gate here, not only at the bridge: a dry_run returns its preview
686 // (and a confirm_token) before it ever reaches RestBridge's policy.
687 if (!PermissionManager::canWriteCalendar($event->calendar_id)) {
688 return MCPHelper::error(
689 'permission_denied',
690 __('You do not have permission to change this event type.', 'fluent-booking'),
691 ['event_id' => $eventId]
692 );
693 }
694
695 $calendarId = (int) $event->calendar_id;
696 $base = '/calendars/' . $calendarId . '/events/' . $eventId;
697
698 if ($action === 'activate' || $action === 'deactivate') {
699 $status = $action === 'activate' ? 'active' : 'draft';
700
701 if ($dryRun) {
702 $preview = [
703 'event' => self::eventSummary($event),
704 'status' => ['from' => $event->status, 'to' => $status],
705 ];
706
707 if ($action === 'deactivate') {
708 // Reversible, but it takes a live booking page offline.
709 $preview['effect'] = __('The public booking page stops offering slots immediately. Existing bookings are untouched.', 'fluent-booking');
710 }
711
712 return self::reversiblePreview($action, $preview);
713 }
714
715 return self::bridged('PUT', $base, ['status' => $status]);
716 }
717
718 if ($action === 'duplicate') {
719 $targetCalendar = absint(Arr::get($params, 'calendar_id')) ?: $calendarId;
720
721 // The target calendar may differ from the one checked above.
722 if ($targetCalendar !== $calendarId && !PermissionManager::canWriteCalendar($targetCalendar)) {
723 return MCPHelper::error(
724 'permission_denied',
725 __('You do not have permission to add event types to the destination calendar.', 'fluent-booking'),
726 ['calendar_id' => $targetCalendar]
727 );
728 }
729
730 if ($dryRun) {
731 return self::additivePreview('duplicate', [
732 'source' => self::eventSummary($event),
733 'destination_calendar' => $targetCalendar,
734 ]);
735 }
736
737 return self::bridged('POST', '/calendars/' . $calendarId . '/clone-event/' . $eventId, [
738 'new_calendar_id' => $targetCalendar,
739 ]);
740 }
741
742 if ($action === 'delete') {
743 return self::deleteEventType($event, $params);
744 }
745
746 if ($action === 'update') {
747 $section = sanitize_text_field(Arr::get($params, 'section', ''));
748
749 $routes = [
750 'details' => $base . '/details',
751 'availability' => $base . '/availability',
752 'limits' => $base . '/limits',
753 'booking_fields' => $base . '/booking-fields',
754 ];
755
756 if (!isset($routes[$section])) {
757 return MCPHelper::error(
758 'missing_section',
759 /* translators: %s: accepted section names */
760 sprintf(__('update needs a section. One of: %s.', 'fluent-booking'), implode(', ', array_keys($routes)))
761 );
762 }
763
764 if (!$fields) {
765 return MCPHelper::error('missing_fields', __('update needs fields. Call get-event-types with event_id to see the current values.', 'fluent-booking'));
766 }
767
768 $payload = self::sectionPayload($event, $section, $fields);
769
770 if (is_wp_error($payload)) {
771 return $payload;
772 }
773
774 // These two rebuild a whole settings block (availability replaces
775 // the weekly grid), so they need a confirm token like
776 // manage-availability update.
777 if (!in_array($section, ['availability', 'limits'], true)) {
778 if ($dryRun) {
779 return self::reversiblePreview('update', [
780 'event' => self::eventSummary($event),
781 'section' => $section,
782 'changing' => array_keys($fields),
783 ]);
784 }
785
786 return self::bridged('POST', $routes[$section], $payload);
787 }
788
789 $tool = 'fluent-booking/manage-event-type';
790 $entityKey = 'event:' . $eventId . ':update:' . $section;
791 $fingerprint = self::eventFingerprint($event);
792 $digest = WriteGuard::paramsDigest($params);
793
794 if ($dryRun) {
795 return MCPHelper::success(WriteGuard::preview($tool, $entityKey, $fingerprint, [
796 'action' => 'update',
797 'event' => self::eventSummary($event),
798 'section' => $section,
799 'changing' => array_keys($fields),
800 'writing' => $payload,
801 'note' => __('Keys you do not send keep their current values. weekly_schedules and date_overrides are replaced whole, so days you omit from them become unavailable.', 'fluent-booking'),
802 ], $digest), [], WriteGuard::CONFIRM_NEXT_STEP);
803 }
804
805 $confirmed = WriteGuard::confirm($tool, $entityKey, $fingerprint, Arr::get($params, 'confirm_token', ''), $digest);
806
807 if (is_wp_error($confirmed)) {
808 return $confirmed;
809 }
810
811 return self::bridged('POST', $routes[$section], $payload);
812 }
813
814 return MCPHelper::error('unsupported_action', __('Unknown action.', 'fluent-booking'));
815 }
816
817 /**
818 * Map a section write from get-event-types' names (`buffer_before_minutes`)
819 * to the admin controller's shape (`settings.buffer_time_before`), seeded
820 * from stored values. The controllers rebuild their whole key set on every
821 * POST, so seeding is what keeps a partial write partial.
822 *
823 * @param CalendarSlot $event
824 * @param string $section
825 * @param array $fields
826 * @return array|\WP_Error
827 */
828 private static function sectionPayload(CalendarSlot $event, $section, $fields)
829 {
830 if ($section === 'details') {
831 return self::detailsPayload($event, $fields);
832 }
833
834 if ($section === 'limits') {
835 return self::limitsPayload($event, $fields);
836 }
837
838 if ($section === 'availability') {
839 return self::availabilityPayload($event, $fields);
840 }
841
842 return self::bookingFieldsPayload($event, $fields);
843 }
844
845 /**
846 * An event type with no location cannot be booked, so neither path may
847 * leave one in that state.
848 *
849 * @param mixed $locations
850 *
851 * @return true|\WP_Error
852 */
853 private static function validateLocations($locations)
854 {
855 $locations = (array) $locations;
856
857 if (!$locations || !Arr::get($locations, '0.type')) {
858 return MCPHelper::error(
859 'location_required',
860 __('An event type needs at least one location, e.g. location_settings: [{"type":"online_meeting"}]. Call list-reference-data with kinds:["location_providers"] for the options.', 'fluent-booking')
861 );
862 }
863
864 return true;
865 }
866
867 /**
868 * Every key updateEventDetails() rebuilds, seeded from what is stored.
869 * The controller defaults anything missing, including location_settings,
870 * which would leave the event unbookable.
871 *
872 * @return array|\WP_Error
873 */
874 private static function detailsPayload(CalendarSlot $event, $fields)
875 {
876 $settings = (array) $event->settings;
877
878 $known = [
879 'title', 'duration', 'status', 'color_schema', 'description',
880 'max_book_per_slot', 'is_display_spots', 'location_settings',
881 'multi_duration',
882 ];
883
884 if ($unknown = array_diff(array_keys($fields), $known)) {
885 return self::unknownSectionFields('details', $unknown, $known);
886 }
887
888 $out = [
889 'title' => $event->title,
890 'duration' => (int) $event->duration,
891 'status' => $event->status,
892 'color_schema' => $event->color_schema ?: '#0099ff',
893 'description' => $event->getDescription(),
894 'max_book_per_slot' => (int) $event->max_book_per_slot,
895 'is_display_spots' => (bool) $event->is_display_spots,
896 'location_settings' => (array) $event->location_settings,
897 'multi_duration' => Arr::get($settings, 'multi_duration', [
898 'enabled' => false,
899 'default_duration' => '',
900 'available_durations' => [],
901 ]),
902 ];
903
904 // Only when sent: omitting it keeps what is stored, but an empty list
905 // would clear the last location.
906 if (array_key_exists('location_settings', $fields)
907 && is_wp_error($locationError = self::validateLocations($fields['location_settings']))) {
908 return $locationError;
909 }
910
911 foreach ($known as $key) {
912 if (array_key_exists($key, $fields)) {
913 $out[$key] = $fields[$key];
914 }
915 }
916
917 return $out;
918 }
919
920 /**
921 * @return array|\WP_Error
922 */
923 private static function limitsPayload(CalendarSlot $event, $fields)
924 {
925 $settings = (array) $event->settings;
926
927 // Every key updateEventLimits() rebuilds, seeded from what is stored.
928 $out = [
929 'schedule_conditions' => Arr::get($settings, 'schedule_conditions', ['value' => 4, 'unit' => 'hours']),
930 'buffer_time_before' => (string) Arr::get($settings, 'buffer_time_before', '0'),
931 'buffer_time_after' => (string) Arr::get($settings, 'buffer_time_after', '0'),
932 'slot_interval' => (string) Arr::get($settings, 'slot_interval', ''),
933 'booking_frequency' => Arr::get($settings, 'booking_frequency', ['enabled' => false, 'limits' => []]),
934 'booking_duration' => Arr::get($settings, 'booking_duration', ['enabled' => false, 'limits' => []]),
935 'lock_timezone' => Arr::get($settings, 'lock_timezone', ['enabled' => false, 'timezone' => '']),
936 ];
937
938 $known = [
939 'buffer_before_minutes', 'buffer_after_minutes', 'slot_interval_minutes',
940 'minimum_notice_minutes', 'booking_frequency', 'booking_duration',
941 'lock_timezone', 'settings',
942 ];
943
944 if ($unknown = array_diff(array_keys($fields), $known)) {
945 return self::unknownSectionFields('limits', $unknown, $known);
946 }
947
948 if (array_key_exists('buffer_before_minutes', $fields)) {
949 $out['buffer_time_before'] = (string) absint($fields['buffer_before_minutes']);
950 }
951
952 if (array_key_exists('buffer_after_minutes', $fields)) {
953 $out['buffer_time_after'] = (string) absint($fields['buffer_after_minutes']);
954 }
955
956 if (array_key_exists('slot_interval_minutes', $fields)) {
957 $out['slot_interval'] = (string) absint($fields['slot_interval_minutes']);
958 }
959
960 if (array_key_exists('minimum_notice_minutes', $fields)) {
961 // Stored as a {value, unit} pair; the projection reports minutes.
962 $out['schedule_conditions'] = [
963 'value' => absint($fields['minimum_notice_minutes']),
964 'unit' => 'minutes',
965 ];
966 }
967
968 foreach (['booking_frequency', 'booking_duration'] as $cap) {
969 if (array_key_exists($cap, $fields)) {
970 $out[$cap] = self::capPayload($fields[$cap]);
971 }
972 }
973
974 if (array_key_exists('lock_timezone', $fields)) {
975 $out['lock_timezone'] = (array) $fields['lock_timezone'];
976 }
977
978 // Escape hatch for a caller that already speaks the controller's shape.
979 $out = array_merge($out, (array) Arr::get($fields, 'settings', []));
980
981 return ['settings' => $out];
982 }
983
984 /**
985 * Rebuild a cap block from the by-unit map the projection returns.
986 * capLimits() turns the stored {unit, value} list into a map; the
987 * controller expects the list.
988 *
989 * @param mixed $cap
990 * @return array
991 */
992 private static function capPayload($cap)
993 {
994 $cap = (array) $cap;
995
996 $limits = Arr::get($cap, 'limits', []);
997
998 // Already a list of {unit, value}: pass it through.
999 if (isset($limits[0])) {
1000 return ['enabled' => Arr::isTrue($cap, 'enabled'), 'limits' => $limits];
1001 }
1002
1003 $rebuilt = [];
1004
1005 foreach ((array) $limits as $unit => $value) {
1006 $rebuilt[] = ['unit' => sanitize_text_field($unit), 'value' => (int) $value];
1007 }
1008
1009 // Some shapes carry the pairs on the block itself.
1010 if (!$rebuilt) {
1011 foreach ($cap as $unit => $value) {
1012 if ($unit !== 'enabled' && is_scalar($value)) {
1013 $rebuilt[] = ['unit' => sanitize_text_field($unit), 'value' => (int) $value];
1014 }
1015 }
1016 }
1017
1018 return ['enabled' => Arr::isTrue($cap, 'enabled'), 'limits' => $rebuilt];
1019 }
1020
1021 /**
1022 * @return array|\WP_Error
1023 */
1024 private static function availabilityPayload(CalendarSlot $event, $fields)
1025 {
1026 $known = ['type', 'schedule_id', 'range_type', 'range_days', 'weekly_schedules', 'date_overrides'];
1027
1028 if ($unknown = array_diff(array_keys($fields), $known)) {
1029 return self::unknownSectionFields('availability', $unknown, $known);
1030 }
1031
1032 $settings = (array) $event->settings;
1033 $timezone = $event->calendar ? $event->calendar->author_timezone : 'UTC';
1034
1035 // Stored hours are UTC, but the controller converts what it receives
1036 // from the author timezone. Convert back first or every slot shifts.
1037 $weekly = SanitizeService::weeklySchedules(
1038 (array) Arr::get($settings, 'weekly_schedules', []),
1039 'UTC',
1040 $timezone,
1041 true
1042 );
1043
1044 $out = [
1045 'schedule_type' => Arr::get($settings, 'schedule_type', 'weekly_schedules'),
1046 'weekly_schedules' => $weekly,
1047 'date_overrides' => Arr::get($settings, 'date_overrides', []),
1048 'range_type' => Arr::get($settings, 'range_type', 'range_days'),
1049 'range_days' => (int) Arr::get($settings, 'range_days', 60),
1050 'range_date_between' => Arr::get($settings, 'range_date_between', ['', '']),
1051 'common_schedule' => Arr::isTrue($settings, 'common_schedule'),
1052 'availability_type' => $event->availability_type,
1053 'availability_id' => (int) $event->availability_id,
1054 ];
1055
1056 if (array_key_exists('type', $fields)) {
1057 $out['availability_type'] = sanitize_text_field($fields['type']);
1058 }
1059
1060 if (array_key_exists('schedule_id', $fields)) {
1061 $scheduleId = absint($fields['schedule_id']);
1062
1063 // The controller does no ownership check on availability_id, so
1064 // an unchecked id would bind another host's schedule and expose its
1065 // hours through the slot tools.
1066 if ($scheduleId) {
1067 $schedule = Availability::find($scheduleId);
1068
1069 if (!$schedule) {
1070 return MCPHelper::error(
1071 'not_found',
1072 __('No availability schedule with that id.', 'fluent-booking'),
1073 ['schedule_id' => $scheduleId]
1074 );
1075 }
1076
1077 if (!self::canReadSchedule($schedule)) {
1078 return MCPHelper::error(
1079 'permission_denied',
1080 __('You do not have permission to use this availability schedule.', 'fluent-booking'),
1081 ['schedule_id' => $scheduleId]
1082 );
1083 }
1084 }
1085
1086 $out['availability_id'] = $scheduleId;
1087 }
1088
1089 foreach (['range_type', 'range_days', 'weekly_schedules', 'date_overrides'] as $key) {
1090 if (array_key_exists($key, $fields)) {
1091 $out[$key] = $fields[$key];
1092 }
1093 }
1094
1095 return $out;
1096 }
1097
1098 /**
1099 * @return array|\WP_Error
1100 */
1101 private static function bookingFieldsPayload(CalendarSlot $event, $fields)
1102 {
1103 $incoming = Arr::get($fields, 'booking_fields');
1104
1105 if (!is_array($incoming) || !$incoming) {
1106 return MCPHelper::error(
1107 'missing_booking_fields',
1108 __('booking_fields must be a list of fields. Call get-event-types with event_id to see the current ones.', 'fluent-booking')
1109 );
1110 }
1111
1112 $stored = (array) $event->getBookingFields();
1113
1114 // Match on name and edit in place. saveEventBookingFields() replaces
1115 // the whole set and names any unnamed entry, and the projection exposes
1116 // `name` as `key`, so unmatched fields would be duplicated.
1117 $byName = [];
1118
1119 foreach ($stored as $key => $field) {
1120 if (is_array($field)) {
1121 $byName[(string) Arr::get($field, 'name', $key)] = $field;
1122 }
1123 }
1124
1125 foreach ($incoming as $field) {
1126 $field = (array) $field;
1127 $name = (string) (Arr::get($field, 'name') ?: Arr::get($field, 'key', ''));
1128
1129 if (!$name) {
1130 // A genuinely new field: let the controller name it.
1131 $byName[] = $field;
1132 continue;
1133 }
1134
1135 unset($field['key']);
1136 $field['name'] = $name;
1137
1138 $byName[$name] = isset($byName[$name])
1139 ? array_merge($byName[$name], $field)
1140 : $field;
1141 }
1142
1143 return ['booking_fields' => array_values($byName)];
1144 }
1145
1146 /**
1147 * @param string $section
1148 * @param array $unknown
1149 * @param array $known
1150 * @return \WP_Error
1151 */
1152 private static function unknownSectionFields($section, $unknown, $known)
1153 {
1154 return MCPHelper::error(
1155 'unknown_section_fields',
1156 sprintf(
1157 /* translators: 1: section name, 2: rejected keys, 3: accepted keys */
1158 __('The %1$s section does not accept: %2$s. It accepts: %3$s.', 'fluent-booking'),
1159 $section,
1160 implode(', ', $unknown),
1161 implode(', ', $known)
1162 )
1163 );
1164 }
1165
1166 /**
1167 * @return array
1168 */
1169 private static function eventSummary(CalendarSlot $event)
1170 {
1171 return [
1172 'id' => (int) $event->id,
1173 'calendar_id' => (int) $event->calendar_id,
1174 'title' => $event->title,
1175 'status' => $event->status,
1176 'event_type' => $event->event_type,
1177 ];
1178 }
1179
1180 /**
1181 * The controller reads a full settings block from the payload, so start
1182 * from the admin's "new event type" defaults (getEventSchema()) and lay the
1183 * agent's fields over them. A title, duration and location are then enough.
1184 *
1185 * @return array|\WP_Error
1186 */
1187 private static function createPayload($calendarId, $fields)
1188 {
1189 $calendar = Calendar::find($calendarId);
1190
1191 if (!$calendar) {
1192 return MCPHelper::error('not_found', __('No calendar with that id.', 'fluent-booking'));
1193 }
1194
1195 $schema = (new CalendarSlot())->getEventSchema($calendar);
1196
1197 // Embedded for the UI, not an event type field.
1198 unset($schema['calendar']);
1199
1200 if (is_wp_error($locationError = self::validateLocations(Arr::get($fields, 'location_settings', [])))) {
1201 return $locationError;
1202 }
1203
1204 // The controller refuses these too, but its messages don't name the
1205 // tool's parameters or valid values.
1206 $required = [
1207 'title' => __('a name for the event type', 'fluent-booking'),
1208 'duration' => __('its length in minutes, e.g. 30', 'fluent-booking'),
1209 'event_type' => sprintf(
1210 /* translators: %s: the accepted event types */
1211 __('one of: %s', 'fluent-booking'),
1212 implode(', ', ContextTools::eventTypes())
1213 ),
1214 ];
1215
1216 foreach ($required as $field => $expected) {
1217 if (Arr::get($fields, $field) === null || Arr::get($fields, $field) === '') {
1218 return MCPHelper::error(
1219 'missing_field',
1220 /* translators: %1$s: the missing field name, %2$s: what it expects */
1221 sprintf(__('create needs fields.%1$s — %2$s.', 'fluent-booking'), $field, $expected),
1222 ['field' => $field, 'required' => array_keys($required)]
1223 );
1224 }
1225 }
1226
1227 $payload = array_merge($schema, $fields);
1228
1229 // Merge settings one level deep so a partial settings block keeps the
1230 // default weekly hours.
1231 $payload['settings'] = array_merge(
1232 (array) Arr::get($schema, 'settings', []),
1233 (array) Arr::get($fields, 'settings', [])
1234 );
1235
1236 return $payload;
1237 }
1238
1239 /**
1240 * Deleting an event type deletes its bookings. The admin doesn't guard
1241 * this, so an agent must pass `force` when any exist.
1242 *
1243 * @return array|\WP_Error
1244 */
1245 private static function deleteEventType(CalendarSlot $event, $params)
1246 {
1247 // CalenderEventCleaner deletes every booking on the event, any status
1248 // or date, with their activities and (in pro) orders and transactions.
1249 // So count them all, not just upcoming ones.
1250 $byStatus = Booking::where('event_id', $event->id)
1251 ->groupBy('status')
1252 ->selectRaw('status, COUNT(*) AS total')
1253 ->pluck('total', 'status')
1254 ->toArray();
1255
1256 $byStatus = array_map('intval', (array) $byStatus);
1257 $total = array_sum($byStatus);
1258
1259 $upcoming = Booking::where('event_id', $event->id)
1260 ->whereIn('status', ['scheduled', 'rescheduled', 'pending'])
1261 ->where('start_time', '>=', gmdate('Y-m-d H:i:s')) // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1262 ->count();
1263
1264 if ($upcoming && !Arr::isTrue($params, 'force')) {
1265 return MCPHelper::error(
1266 'has_future_bookings',
1267 /* translators: %1$d: upcoming bookings, %2$d: bookings in total */
1268 sprintf(__('%1$d upcoming bookings would be deleted with this event type, and %2$d bookings in total including past and cancelled ones. Cancel or move them first, or pass force to delete them too.', 'fluent-booking'), $upcoming, $total),
1269 ['upcoming_bookings' => $upcoming, 'total_bookings' => $total, 'bookings_by_status' => $byStatus]
1270 );
1271 }
1272
1273 if ($total && !Arr::isTrue($params, 'force')) {
1274 return MCPHelper::error(
1275 'has_bookings',
1276 /* translators: %d: bookings in total */
1277 sprintf(__('%d past or cancelled bookings would be deleted with this event type, along with their activity history and any payment records. Nothing here is recoverable. Pass force to proceed.', 'fluent-booking'), $total),
1278 ['upcoming_bookings' => 0, 'total_bookings' => $total, 'bookings_by_status' => $byStatus]
1279 );
1280 }
1281
1282 $tool = 'fluent-booking/manage-event-type';
1283 $entityKey = 'event:' . $event->id . ':delete';
1284 $digest = WriteGuard::paramsDigest($params);
1285
1286 $fingerprint = self::eventFingerprint($event) . '|' . $upcoming . '|' . $total;
1287
1288 if (Arr::isTrue($params, 'dry_run')) {
1289 return MCPHelper::success(WriteGuard::preview($tool, $entityKey, $fingerprint, [
1290 'action' => 'delete',
1291 'event' => self::eventSummary($event),
1292 'upcoming_bookings' => $upcoming,
1293 'total_bookings' => $total,
1294 'bookings_by_status' => $byStatus,
1295 'note' => $total
1296 ? __('Every booking on this event type is deleted with it — past, cancelled and upcoming alike — along with their activity history and any payment records. Attendees are not notified.', 'fluent-booking')
1297 : __('This event type has no bookings, so nothing else is removed with it.', 'fluent-booking'),
1298 ], $digest), [], WriteGuard::CONFIRM_NEXT_STEP);
1299 }
1300
1301 $confirmed = WriteGuard::confirm($tool, $entityKey, $fingerprint, Arr::get($params, 'confirm_token', ''), $digest);
1302
1303 if (is_wp_error($confirmed)) {
1304 return $confirmed;
1305 }
1306
1307 return self::bridged('DELETE', '/calendars/' . $event->calendar_id . '/events/' . $event->id);
1308 }
1309
1310 /**
1311 * @param array $params
1312 * @return array|\WP_Error
1313 */
1314 public static function listReferenceData($params = [])
1315 {
1316 $kinds = array_values(array_filter((array) Arr::get($params, 'kinds', [])));
1317
1318 $unknown = array_diff($kinds, self::REFERENCE_KINDS);
1319
1320 if ($unknown) {
1321 return MCPHelper::error(
1322 'unknown_kind',
1323 /* translators: %1$s: rejected kinds, %2$s: accepted kinds */
1324 sprintf(__('Unknown kinds: %1$s. Available: %2$s.', 'fluent-booking'), implode(', ', $unknown), implode(', ', self::REFERENCE_KINDS))
1325 );
1326 }
1327
1328 if (!$kinds) {
1329 return MCPHelper::error('missing_kinds', __('Name at least one kind.', 'fluent-booking'));
1330 }
1331
1332 $data = [];
1333
1334 foreach ($kinds as $kind) {
1335 $result = self::referenceKind($kind, $params);
1336
1337 if (is_wp_error($result)) {
1338 return $result;
1339 }
1340
1341 $data[$kind] = self::referenceEnvelope($result);
1342 }
1343
1344 return MCPHelper::success($data, ['scope' => PermissionGate::currentScope()]);
1345 }
1346
1347 /**
1348 * Give every reference list the same shape, and say when it was cut short.
1349 *
1350 * @param array $result [$rows, $total]
1351 *
1352 * @return array
1353 */
1354 private static function referenceEnvelope($result)
1355 {
1356 list($items, $total) = $result;
1357
1358 $envelope = [
1359 'items' => $items,
1360 'total' => (int) $total,
1361 ];
1362
1363 if ($total > count($items)) {
1364 $envelope['truncated'] = true;
1365 $envelope['truncation_note'] = sprintf(
1366 /* translators: 1: number returned, 2: number that exist */
1367 __('Showing %1$d of %2$d. Narrow the request; this list is capped.', 'fluent-booking'),
1368 count($items),
1369 (int) $total
1370 );
1371 }
1372
1373 return $envelope;
1374 }
1375
1376 /**
1377 * @return array|\WP_Error [$rows, $total]
1378 */
1379 private static function referenceKind($kind, $params)
1380 {
1381 if ($kind === 'calendars') {
1382 // Same scope as canReadCalendar(), which admits shared team
1383 // calendars; a user_id filter would hide ids the agent may use.
1384 $query = PermissionGate::scopeToReadableCalendars(Calendar::orderBy('id', 'asc'), 'id');
1385
1386 $total = (clone $query)->count();
1387 $rows = [];
1388
1389 foreach ($query->limit(self::LIST_LIMIT)->get() as $calendar) {
1390 $rows[] = [
1391 'id' => (int) $calendar->id,
1392 'title' => $calendar->title,
1393 'slug' => $calendar->slug,
1394 'type' => $calendar->type,
1395 'host_id' => (int) $calendar->user_id,
1396 'timezone' => $calendar->author_timezone,
1397 ];
1398 }
1399
1400 return [$rows, $total];
1401 }
1402
1403 if ($kind === 'hosts') {
1404 // Hosts are calendar owners. Listing WP users would leak every account.
1405 $query = PermissionGate::scopeToReadableCalendars(Calendar::query(), 'id');
1406
1407 // Skip calendars whose owner was deleted, in SQL so the total stays right.
1408 $query->whereIn('user_id', User::select('ID'));
1409
1410 $total = (clone $query)->distinct()->count('user_id');
1411
1412 // Distinct owners, capped in SQL. Deriving hosts from a capped page
1413 // of calendars could silently drop one.
1414 $userIds = array_map('intval', (clone $query)
1415 ->groupBy('user_id')
1416 ->orderByRaw('min(id) asc')
1417 ->limit(self::LIST_LIMIT)
1418 ->pluck('user_id')
1419 ->toArray());
1420
1421 // One query for the lot rather than one per host.
1422 if ($userIds) {
1423 cache_users($userIds);
1424 }
1425
1426 $rows = [];
1427
1428 foreach ($userIds as $userId) {
1429 $user = get_userdata($userId);
1430
1431 if (!$user) {
1432 continue;
1433 }
1434
1435 $rows[] = [
1436 'id' => (int) $userId,
1437 'name' => MCPHelper::untrusted($user->display_name, 200),
1438 'email' => PermissionGate::canSeeAllBookings() ? $user->user_email : MCPHelper::maskEmail($user->user_email),
1439 ];
1440 }
1441
1442 return [$rows, $total];
1443 }
1444
1445 if ($kind === 'availability_schedules') {
1446 $query = Availability::orderBy('id', 'desc');
1447
1448 if (!PermissionManager::userCan(['manage_all_data', 'read_and_use_other_availabilities', 'manage_other_availabilities'])) {
1449 $query->where('object_id', get_current_user_id());
1450 }
1451
1452 $total = (clone $query)->count();
1453 $rows = [];
1454
1455 foreach ($query->limit(self::LIST_LIMIT)->get() as $schedule) {
1456 $rows[] = [
1457 'id' => (int) $schedule->id,
1458 'title' => $schedule->key,
1459 'host_id' => (int) $schedule->object_id,
1460 'default' => Arr::isTrue($schedule, 'value.default'),
1461 'timezone' => Arr::get($schedule, 'value.timezone', 'UTC'),
1462 ];
1463 }
1464
1465 return [$rows, $total];
1466 }
1467
1468 if ($kind === 'location_providers') {
1469 $rows = [];
1470
1471 // Flatten the picker's groups. `disabled` marks Pro-only providers.
1472 foreach ((new CalendarSlot())->getLocationFields() as $group) {
1473 foreach ((array) Arr::get($group, 'options', []) as $type => $option) {
1474 $rows[] = [
1475 'type' => $type,
1476 'title' => Arr::get($option, 'title', ''),
1477 'group' => Arr::get($group, 'label', ''),
1478 'disabled' => Arr::isTrue($option, 'disabled'),
1479 ];
1480 }
1481 }
1482
1483 return [$rows, count($rows)];
1484 }
1485
1486 // booking_fields
1487 $eventId = absint(Arr::get($params, 'event_id'));
1488
1489 if (!$eventId) {
1490 return MCPHelper::error('missing_event_id', __('booking_fields are per event type, so event_id is required.', 'fluent-booking'));
1491 }
1492
1493 $event = CalendarSlot::find($eventId);
1494
1495 if (!$event) {
1496 return MCPHelper::error('not_found', __('No event type with that id.', 'fluent-booking'));
1497 }
1498
1499 if (!PermissionManager::canReadCalendar($event->calendar_id)) {
1500 return MCPHelper::error('permission_denied', __('You do not have permission to read this event type.', 'fluent-booking'));
1501 }
1502
1503 $rows = [];
1504
1505 // getBookingFields() merges built-in fields over stored ones, and is
1506 // what get-event-types reads too.
1507 foreach ((array) $event->getBookingFields() as $field) {
1508 if (!is_array($field)) {
1509 continue;
1510 }
1511
1512 $rows[] = [
1513 'name' => Arr::get($field, 'name'),
1514 'label' => Arr::get($field, 'label'),
1515 'type' => Arr::get($field, 'type'),
1516 'required' => Arr::isTrue($field, 'required'),
1517 'enabled' => Arr::isTrue($field, 'enabled'),
1518 ];
1519 }
1520
1521 return [$rows, count($rows)];
1522 }
1523
1524 /**
1525 * Usage counts for a page of schedules in one grouped query.
1526 *
1527 * @param array $scheduleIds
1528 *
1529 * @return array schedule id => count
1530 */
1531 private static function usageCounts($scheduleIds)
1532 {
1533 if (!$scheduleIds) {
1534 return [];
1535 }
1536
1537 $rows = CalendarSlot::where('availability_type', 'existing_schedule')
1538 ->whereIn('availability_id', $scheduleIds)
1539 ->selectRaw('availability_id, COUNT(*) as usage_count')
1540 ->groupBy('availability_id')
1541 ->get();
1542
1543 $counts = [];
1544
1545 foreach ($rows as $row) {
1546 $counts[(int) $row->availability_id] = (int) $row->usage_count;
1547 }
1548
1549 return $counts;
1550 }
1551
1552 /**
1553 * @return array
1554 */
1555 private static function projectSchedule(Availability $schedule, $timezone, $full, $usageCount = null)
1556 {
1557 $own = Arr::get($schedule, 'value.timezone', 'UTC');
1558
1559 // Only honour a valid zone, and never label hours with a zone they
1560 // aren't expressed in.
1561 $requested = $timezone && in_array($timezone, timezone_identifiers_list(), true) ? $timezone : '';
1562
1563 $row = [
1564 'id' => (int) $schedule->id,
1565 'title' => $schedule->key,
1566 'host_id' => (int) $schedule->object_id,
1567 'default' => Arr::isTrue($schedule, 'value.default'),
1568 // The zone the hours are in. Summary rows have no hours, so it is
1569 // always the schedule's own there.
1570 'timezone' => $full && $requested ? $requested : $own,
1571 // The list hands its count in; a single schedule looks its own up.
1572 'usage_count' => $usageCount === null
1573 ? AvailabilityService::getAvailabilityUsageCount($schedule->id)
1574 : (int) $usageCount,
1575 ];
1576
1577 if (!$full) {
1578 return $row;
1579 }
1580
1581 // Hours are stored in UTC. getFormattedSchedule() only renders the
1582 // schedule's own zone, so convert for any other.
1583 if ($requested && $requested !== $own) {
1584 $row['weekly_schedules'] = SanitizeService::weeklySchedules(Arr::get($schedule, 'value.weekly_schedules', []), 'UTC', $requested);
1585 $row['date_overrides'] = SanitizeService::slotDateOverrides(Arr::get($schedule, 'value.date_overrides', []), 'UTC', $requested);
1586 $row['stored_timezone'] = $own;
1587 $row['note'] = sprintf(
1588 /* translators: 1: requested timezone, 2: the schedule's own timezone */
1589 __('Hours converted to %1$s. The schedule is configured in %2$s — send edits back in its own zone, or pass the same timezone again.', 'fluent-booking'),
1590 $requested,
1591 $own
1592 );
1593
1594 return $row;
1595 }
1596
1597 $formatted = AvailabilityService::getFormattedSchedule($schedule);
1598
1599 // Pick the grids out; the admin shape also carries a gravatar URL.
1600 $row['weekly_schedules'] = Arr::get($formatted, 'settings.weekly_schedules', []);
1601 $row['date_overrides'] = Arr::get($formatted, 'settings.date_overrides', []);
1602
1603 return $row;
1604 }
1605
1606 /**
1607 * Stricter than canReadSchedule(): `read_and_use_other_availabilities`
1608 * lets a host use a colleague's hours but not edit them.
1609 *
1610 * @return bool
1611 */
1612 private static function canWriteSchedule(Availability $schedule)
1613 {
1614 if ((int) $schedule->object_id === get_current_user_id()) {
1615 return true;
1616 }
1617
1618 return PermissionManager::userCan(['manage_all_data', 'manage_other_availabilities']);
1619 }
1620
1621 /**
1622 * @return bool
1623 */
1624 private static function canReadSchedule(Availability $schedule)
1625 {
1626 if ((int) $schedule->object_id === get_current_user_id()) {
1627 return true;
1628 }
1629
1630 return PermissionManager::userCan(['manage_all_data', 'read_and_use_other_availabilities', 'manage_other_availabilities']);
1631 }
1632
1633 /**
1634 * @return array|\WP_Error
1635 */
1636 private static function bridged($method, $route, $body = [])
1637 {
1638 $result = RestBridge::call($method, $route, $body);
1639
1640 if (is_wp_error($result)) {
1641 return $result;
1642 }
1643
1644 // Admin responses carry model dumps for the SPA to re-render. Keep the
1645 // message and the identity; drop the rest.
1646 $out = ['message' => Arr::get($result, 'message', __('Done.', 'fluent-booking'))];
1647
1648 foreach (['schedule', 'slot', 'calendar_event', 'event'] as $key) {
1649 $record = Arr::get($result, $key);
1650
1651 if (!$record) {
1652 continue;
1653 }
1654
1655 // Models come back as objects, and an array cast hides their attributes.
1656 $out['id'] = is_object($record) ? (int) $record->id : (int) Arr::get((array) $record, 'id', 0);
1657 break;
1658 }
1659
1660 return MCPHelper::success($out);
1661 }
1662 }
1663