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

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

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