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

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

770 lines 34.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Tools;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\CalendarSlot;
7 use FluentBooking\App\Modules\MCP\Support\BookingProjector;
8 use FluentBooking\App\Modules\MCP\Support\BookingWriter;
9 use FluentBooking\App\Modules\MCP\Support\MCPHelper;
10 use FluentBooking\App\Modules\MCP\Support\PermissionGate;
11 use FluentBooking\App\Modules\MCP\Support\SlotResolver;
12 use FluentBooking\App\Modules\MCP\Support\WriteGuard;
13 use FluentBooking\App\Services\PermissionManager;
14 use FluentBooking\Framework\Support\Arr;
15
16 defined('ABSPATH') || exit;
17
18 /**
19 * The two tools that change something.
20 *
21 * `create-booking` stands alone because its parameter shape — attendee details,
22 * custom fields, guests, location — shares nothing with the others. Everything
23 * that acts on a booking that already exists goes through `manage-booking`
24 * behind an `action` enum, which is where Cal.com spends six separate tools.
25 *
26 * Destructive actions will not execute without a confirm_token minted by a dry
27 * run. The token is bound to BOTH a fingerprint of the booking's current state
28 * — so a booking that moved while the agent was thinking cannot be acted on
29 * with stale numbers — AND a digest of the parameters that were previewed, so
30 * the change that executes is the change a human approved. Reversible actions
31 * (complete, no_show, resend_email, and update_details on anything but the
32 * email address) execute directly.
33 *
34 * "Destructive" is decided per call rather than per action name, because two of
35 * them are only destructive sometimes. See needsConfirmation().
36 *
37 * @see \FluentBooking\App\Modules\MCP\Support\WriteGuard for the contract.
38 */
39 class BookingWriteTools
40 {
41 /**
42 * Actions that must be previewed before they can execute. These either
43 * cannot be undone from the agent's side (cancel, reject) or move a real
44 * person's calendar entry (create, reschedule).
45 */
46 const DESTRUCTIVE_ACTIONS = ['reschedule', 'cancel', 'reject'];
47
48 /**
49 * Ceiling on `guests`. Enforced in the schema so an oversized payload is
50 * refused before WordPress decodes it and the sanitizer walks every entry —
51 * the per-event seat limit further down only applies after all that work.
52 */
53 const MAX_GUESTS = 50;
54
55 public static function definitions()
56 {
57 return [
58 'fluent-booking/create-booking' => [
59 'label' => __('Create booking', 'fluent-booking'),
60 'description' => __('Book a slot on an attendee\'s behalf. Availability is re-checked at execute time and everyone is emailed as they would be for a self-service booking. Call with dry_run first, then pass back the confirm_token AND the same parameters.', 'fluent-booking'),
61 'input_schema' => [
62 'type' => 'object',
63 'properties' => [
64 'event_id' => [
65 'type' => 'integer',
66 'description' => __('The event type to book. Required.', 'fluent-booking'),
67 ],
68 'start_time' => [
69 'type' => 'string',
70 'description' => __('Local wall-clock start, Y-m-d H:i:s, read in the timezone parameter. No offset or Z suffix. Required.', 'fluent-booking'),
71 ],
72 'timezone' => [
73 'type' => 'string',
74 'description' => __('IANA timezone the attendee is booking in. Defaults to the site timezone.', 'fluent-booking'),
75 ],
76 'name' => [
77 'type' => 'string',
78 'description' => __('Attendee full name. Required.', 'fluent-booking'),
79 'maxLength' => 200,
80 ],
81 'email' => [
82 'type' => 'string',
83 'description' => __('Attendee email. Required.', 'fluent-booking'),
84 'maxLength' => 254,
85 ],
86 'phone' => ['type' => 'string', 'maxLength' => 40],
87 'message' => [
88 'type' => 'string',
89 'description' => __('The attendee\'s note, shown to the host.', 'fluent-booking'),
90 'maxLength' => 5000,
91 ],
92 'internal_note' => [
93 'type' => 'string',
94 'description' => __('Host-only note. Never shown to the attendee.', 'fluent-booking'),
95 'maxLength' => 5000,
96 ],
97 'duration' => [
98 'type' => 'integer',
99 'description' => __('Minutes, when the event type offers a choice. Defaults to its default duration.', 'fluent-booking'),
100 ],
101 'host_id' => [
102 'type' => 'integer',
103 'description' => __('Pin a specific host. Round-robin events pick one automatically when this is omitted.', 'fluent-booking'),
104 ],
105 'location_type' => [
106 'type' => 'string',
107 'description' => __('Only when the event type offers several. get-event-types lists them.', 'fluent-booking'),
108 ],
109 'location_description' => [
110 'type' => 'string',
111 'description' => __('Required for phone_guest and in_person_guest: the attendee\'s number or address.', 'fluent-booking'),
112 ],
113 'custom_fields' => [
114 'type' => 'object',
115 'description' => __('Answers to the event type\'s booking fields, keyed by field name.', 'fluent-booking'),
116 ],
117 'guests' => [
118 'type' => 'array',
119 'description' => __('Additional guests. Email strings, or {name, email} objects — group events seat each guest separately and need a name.', 'fluent-booking'),
120 'items' => ['type' => ['string', 'object']],
121 'maxItems' => self::MAX_GUESTS,
122 ],
123 'send_notifications' => [
124 'type' => 'boolean',
125 'description' => __('Default true. Set false to create the booking without notifying anyone — email or SMS; reminders are still scheduled.', 'fluent-booking'),
126 ],
127 'dry_run' => [
128 'type' => 'boolean',
129 'description' => __('Preview the booking and get a confirm_token without creating anything.', 'fluent-booking'),
130 ],
131 'confirm_token' => [
132 'type' => 'string',
133 'description' => __('The token from the dry run. Required to actually create the booking.', 'fluent-booking'),
134 ],
135 'idempotency_key' => [
136 'type' => 'string',
137 'description' => __('A unique key, so a retry after a timeout returns the first result rather than booking twice.', 'fluent-booking'),
138 ],
139 ],
140 'required' => ['event_id', 'start_time', 'name', 'email'],
141 ],
142 'annotations' => [
143 'title' => __('Create booking', 'fluent-booking'),
144 'readonly' => false,
145 'destructive' => true,
146 'idempotent' => false,
147 ],
148 'permission_callback' => [PermissionGate::class, 'bookingWriteGate'],
149 'execute_callback' => [self::class, 'createBooking'],
150 ],
151
152 'fluent-booking/manage-booking' => [
153 'label' => __('Manage booking', 'fluent-booking'),
154 'description' => __('Act on an existing booking: reschedule, cancel, confirm, reject, complete, mark no-show, edit attendee details, or resend the confirmation email. reschedule, cancel and reject are destructive — call with dry_run first, then pass the returned confirm_token.', 'fluent-booking'),
155 'input_schema' => [
156 'type' => 'object',
157 'properties' => [
158 'booking_id' => [
159 'type' => 'integer',
160 'description' => __('The booking to act on. Required.', 'fluent-booking'),
161 ],
162 'action' => [
163 'type' => 'string',
164 'description' => __('reschedule needs start_time (and timezone). cancel and reject take an optional reason shown to the attendee. update_details needs fields. resend_email takes recipient. Required.', 'fluent-booking'),
165 'enum' => ['reschedule', 'cancel', 'confirm', 'reject', 'complete', 'no_show', 'update_details', 'resend_email'],
166 ],
167 'start_time' => [
168 'type' => 'string',
169 'description' => __('reschedule only. Local wall-clock start, Y-m-d H:i:s, read in the timezone parameter.', 'fluent-booking'),
170 ],
171 'timezone' => [
172 'type' => 'string',
173 'description' => __('IANA timezone for start_time and for the *_local times in the response.', 'fluent-booking'),
174 ],
175 'host_id' => [
176 'type' => 'integer',
177 'description' => __('reschedule only. Pin a host on a round-robin event.', 'fluent-booking'),
178 ],
179 'reason' => [
180 'type' => 'string',
181 'description' => __('Why. Stored on the booking and included in the cancellation, rejection or reschedule email.', 'fluent-booking'),
182 'maxLength' => 2000,
183 ],
184 'fields' => [
185 'type' => 'object',
186 'description' => __('update_details only. Any of: first_name, last_name, email, phone, internal_note.', 'fluent-booking'),
187 ],
188 'recipient' => [
189 'type' => 'string',
190 'description' => __('resend_email only. Who to send the confirmation to. Defaults to guest.', 'fluent-booking'),
191 'enum' => ['guest', 'host'],
192 ],
193 'refund_payment' => [
194 'type' => 'boolean',
195 'description' => __('cancel and reject only. Refund through the original gateway. Default false — money never moves as a side effect.', 'fluent-booking'),
196 ],
197 'send_notifications' => [
198 'type' => 'boolean',
199 'description' => __('Default true. Set false to make the change without notifying anyone — email or SMS; reminders are still scheduled.', 'fluent-booking'),
200 ],
201 'dry_run' => [
202 'type' => 'boolean',
203 'description' => __('Preview the change and get a confirm_token without applying it.', 'fluent-booking'),
204 ],
205 'confirm_token' => [
206 'type' => 'string',
207 'description' => __('The token from the dry run. Required for reschedule, cancel and reject.', 'fluent-booking'),
208 ],
209 'idempotency_key' => [
210 'type' => 'string',
211 'description' => __('Pass a unique key so a retry after a timeout returns the first result instead of acting twice.', 'fluent-booking'),
212 ],
213 ],
214 'required' => ['booking_id', 'action'],
215 ],
216 'annotations' => [
217 'title' => __('Manage booking', 'fluent-booking'),
218 'readonly' => false,
219 'destructive' => true,
220 'idempotent' => false,
221 ],
222 'permission_callback' => [PermissionGate::class, 'bookingWriteGate'],
223 'execute_callback' => [self::class, 'manageBooking'],
224 ],
225 ];
226 }
227
228 /**
229 * @param array $params
230 * @return array|\WP_Error
231 */
232 public static function createBooking($params = [])
233 {
234 $eventId = absint(Arr::get($params, 'event_id'));
235
236 if (!$eventId) {
237 return MCPHelper::error('missing_event_id', __('event_id is required. Call get-event-types to find one.', 'fluent-booking'));
238 }
239
240 $event = CalendarSlot::with('calendar')->find($eventId);
241
242 if (!$event) {
243 return MCPHelper::error('event_not_found', __('No event type with that id.', 'fluent-booking'));
244 }
245
246 if (!PermissionManager::canWriteCalendar($event->calendar_id)) {
247 return MCPHelper::error(
248 'permission_denied',
249 __('You do not have permission to create bookings on this calendar.', 'fluent-booking'),
250 ['event_id' => $eventId]
251 );
252 }
253
254 $tool = 'fluent-booking/create-booking';
255 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
256 // A create has no existing entity, so the token is bound to the exact
257 // slot being claimed. Two agents previewing the same slot mint separate
258 // user-scoped tokens; the availability re-check at execute time is what
259 // stops the second one from double-booking.
260 $entityKey = 'event:' . $eventId . ':' . Arr::get($params, 'start_time', '') . ':' . strtolower((string) Arr::get($params, 'email', ''));
261
262 $digest = WriteGuard::paramsDigest($params);
263
264 if (Arr::isTrue($params, 'dry_run')) {
265 $preview = self::previewCreate($event, $params, $timezone);
266
267 if (is_wp_error($preview)) {
268 return $preview;
269 }
270
271 return MCPHelper::success(
272 WriteGuard::preview($tool, $entityKey, self::createFingerprint($event), $preview, $digest),
273 ['timezone' => $timezone],
274 WriteGuard::CONFIRM_NEXT_STEP
275 );
276 }
277
278 // idempotent() OUTSIDE confirm(), not the other way round. confirm()
279 // consumes the token, so with the old ordering the retry-after-timeout
280 // this key exists to absorb was rejected as `confirmation_expired`
281 // before the recorded result was ever consulted — and the agent's
282 // recovery path was a fresh dry_run and a second booking.
283 return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $event, $params, $timezone, $digest) {
284 $confirmed = WriteGuard::confirm($tool, $entityKey, self::createFingerprint($event), Arr::get($params, 'confirm_token', ''), $digest);
285
286 if (is_wp_error($confirmed)) {
287 return $confirmed;
288 }
289
290 $booking = BookingWriter::create($event, $params);
291
292 if (is_wp_error($booking)) {
293 return $booking;
294 }
295
296 $data = [
297 'created' => true,
298 'booking' => BookingProjector::full($booking, $timezone),
299 ];
300
301 // A guest the caller asked for and did not get has to be named. An
302 // agent told `created: true` for a four-person booking that seated
303 // one otherwise reports a wrong number as a right one.
304 if ($dropped = BookingWriter::droppedGuests()) {
305 $data['guests_dropped'] = $dropped;
306 }
307
308 return MCPHelper::success(
309 $data,
310 [
311 'timezone' => $timezone,
312 'notifications_requested' => BookingWriter::wantsNotifications($params),
313 ],
314 'Call get-booking with this booking_id to see the full record, or list-bookings to confirm it appears in the schedule.'
315 );
316 }, $digest, function ($ref) use ($timezone) {
317 return self::replayBooking($ref, $timezone, ['created' => true]);
318 });
319 }
320
321 /**
322 * @param array $params
323 * @return array|\WP_Error
324 */
325 public static function manageBooking($params = [])
326 {
327 $action = sanitize_text_field(Arr::get($params, 'action', ''));
328
329 if (!$action) {
330 return MCPHelper::error('missing_action', __('action is required.', 'fluent-booking'));
331 }
332
333 $bookingId = absint(Arr::get($params, 'booking_id'));
334
335 if (!$bookingId) {
336 return MCPHelper::error('missing_booking_id', __('booking_id is required. Call list-bookings to find one.', 'fluent-booking'));
337 }
338
339 $booking = Booking::with(['calendar_event'])->find($bookingId);
340
341 if (!$booking) {
342 return MCPHelper::error('booking_not_found', __('No booking with that id.', 'fluent-booking'));
343 }
344
345 if (!BookingWriter::canWriteBooking($booking)) {
346 return MCPHelper::error(
347 'permission_denied',
348 __('You do not have permission to change this booking.', 'fluent-booking'),
349 ['booking_id' => $bookingId]
350 );
351 }
352
353 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
354 $tool = 'fluent-booking/manage-booking';
355 $entityKey = 'booking:' . $bookingId . ':' . $action;
356 $digest = WriteGuard::paramsDigest($params);
357
358 $needsConfirmation = self::needsConfirmation($booking, $action, $params);
359
360 if (Arr::isTrue($params, 'dry_run')) {
361 $preview = self::previewAction($booking, $action, $params, $timezone);
362
363 if (is_wp_error($preview)) {
364 return $preview;
365 }
366
367 if (!$needsConfirmation) {
368 // Reversible actions still honour dry_run, because an agent that
369 // previews everything by habit should not be punished for it —
370 // it just does not need a token to follow up.
371 return MCPHelper::success(
372 ['dry_run' => true, 'preview' => $preview],
373 ['timezone' => $timezone],
374 'Nothing was changed. This action is reversible — call again without dry_run to apply it; no confirm_token is needed.'
375 );
376 }
377
378 return MCPHelper::success(
379 WriteGuard::preview($tool, $entityKey, WriteGuard::bookingFingerprint($booking), $preview, $digest),
380 ['timezone' => $timezone],
381 WriteGuard::CONFIRM_NEXT_STEP
382 );
383 }
384
385 // See createBooking(): the idempotency wrapper has to sit OUTSIDE the
386 // confirm-token check, because the check is one-shot.
387 return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $booking, $action, $params, $timezone, $digest, $needsConfirmation) {
388 if ($needsConfirmation) {
389 $confirmed = WriteGuard::confirm(
390 $tool,
391 $entityKey,
392 WriteGuard::bookingFingerprint($booking),
393 Arr::get($params, 'confirm_token', ''),
394 $digest
395 );
396
397 if (is_wp_error($confirmed)) {
398 return $confirmed;
399 }
400 }
401
402 return self::execute($booking, $action, $params, $timezone);
403 }, $digest, function ($ref) use ($timezone, $action) {
404 return self::replayBooking($ref, $timezone, ['action' => $action]);
405 });
406 }
407
408 /**
409 * Rebuild a write's response from the reference the idempotency record
410 * keeps, reading the booking as it stands now.
411 *
412 * The record itself holds ids only — see WriteGuard::idempotent() — so a
413 * replay re-projects rather than handing back a day-old copy of the
414 * attendee's details.
415 *
416 * @param array $ref
417 * @param string $timezone
418 * @param array $extra
419 *
420 * @return array|null
421 */
422 private static function replayBooking($ref, $timezone, $extra = [])
423 {
424 $bookingId = (int) Arr::get($ref, 'booking_id');
425
426 if (!$bookingId) {
427 return null;
428 }
429
430 $booking = Booking::with(['calendar_event'])->find($bookingId);
431
432 if (!$booking) {
433 return null;
434 }
435
436 return MCPHelper::success(
437 $extra + ['booking' => BookingProjector::full($booking, $timezone)],
438 ['timezone' => $timezone]
439 );
440 }
441
442 /**
443 * Whether this particular call has to be previewed and confirmed first.
444 *
445 * Most of the answer is the action name, but two cases are only destructive
446 * depending on what is being asked, and both were previously waved through
447 * as "reversible":
448 *
449 * - `update_details` changing `email`. Reversible in the database and not
450 * reversible anywhere else: the address becomes the delivery target for
451 * `resend_email`, which carries the meeting join link and lands at the
452 * new address without the real attendee hearing about it. Rewriting a
453 * booking's contact address is not an edit, it is a redirection.
454 * - `confirm` on a booking with an unsettled payment order. It marks the
455 * order paid and fires the payment-completed hooks. Nothing about a
456 * booking's money state should move without the operator seeing it
457 * first.
458 *
459 * @param Booking $booking
460 * @param string $action
461 * @param array $params
462 * @return bool
463 */
464 private static function needsConfirmation(Booking $booking, $action, $params)
465 {
466 if (in_array($action, self::DESTRUCTIVE_ACTIONS, true)) {
467 return true;
468 }
469
470 if ($action === 'update_details') {
471 $fields = (array) Arr::get($params, 'fields', []);
472
473 return array_key_exists('email', $fields);
474 }
475
476 if ($action === 'confirm') {
477 return $booking->payment_method
478 && $booking->payment_status !== 'paid'
479 && $booking->payment_order;
480 }
481
482 return false;
483 }
484
485 /**
486 * @return array|\WP_Error
487 */
488 private static function execute(Booking $booking, $action, $params, $timezone)
489 {
490 if ($action === 'resend_email') {
491 $result = BookingWriter::resendEmail($booking, sanitize_text_field(Arr::get($params, 'recipient', 'guest')), $params);
492
493 if (is_wp_error($result)) {
494 return $result;
495 }
496
497 return MCPHelper::success(['action' => $action] + $result, ['timezone' => $timezone]);
498 }
499
500 if ($action === 'update_details') {
501 $result = BookingWriter::updateDetails($booking, Arr::get($params, 'fields', []), $params);
502 } elseif ($action === 'reschedule') {
503 $result = BookingWriter::reschedule($booking, $params);
504 } else {
505 $result = BookingWriter::applyStatus($booking, $action, $params);
506 }
507
508 if (is_wp_error($result)) {
509 return $result;
510 }
511
512 return MCPHelper::success(
513 [
514 'action' => $action,
515 'booking' => BookingProjector::full($result, $timezone),
516 ],
517 [
518 'timezone' => $timezone,
519 'notifications_requested' => BookingWriter::wantsNotifications($params),
520 ]
521 );
522 }
523
524 /**
525 * What a create would do, without doing it. Deliberately names every
526 * recipient: the operator reading the agent's transcript should be able to
527 * see who is about to be emailed before approving.
528 *
529 * @return array|\WP_Error
530 */
531 private static function previewCreate(CalendarSlot $event, $params, $timezone)
532 {
533 // The same checks the execute runs, so "the dry run worked" means something.
534 $valid = BookingWriter::validateCreate($event, $params);
535
536 if (is_wp_error($valid)) {
537 return $valid;
538 }
539
540 $notify = BookingWriter::wantsNotifications($params);
541
542 $preview = [
543 'action' => 'create',
544 'event' => [
545 'id' => (int) $event->id,
546 'title' => $event->title,
547 'type' => $event->event_type,
548 'duration' => (int) $event->getDuration(Arr::get($params, 'duration')),
549 'status' => $event->status,
550 ],
551 'attendee' => [
552 'name' => sanitize_text_field(Arr::get($params, 'name', '')),
553 'email' => MCPHelper::maskEmail(Arr::get($params, 'email', '')),
554 ],
555 'requested_start' => sanitize_text_field(Arr::get($params, 'start_time', '')),
556 'timezone' => $timezone,
557 'slot_available' => self::previewSlotAvailability($event, $params, $timezone),
558 'guests' => count((array) Arr::get($params, 'guests', [])),
559 'guests_bookable' => BookingWriter::previewGuestCount($event, $params),
560 'will_notify' => $notify ? self::recipientSummary($event) : [],
561 'notifications_requested' => $notify,
562 'note' => __('Availability is re-checked when you execute, so a slot taken in the meantime is refused rather than double-booked.', 'fluent-booking'),
563 ];
564
565 if ($preview['slot_available'] === false) {
566 $preview['note'] = __('That time is not currently free, so executing this would be refused. Call get-available-slots for the current openings.', 'fluent-booking');
567 }
568
569 if ($ambiguity = MCPHelper::ambiguityNote(Arr::get($params, 'start_time', ''), $timezone)) {
570 $preview['timezone_warning'] = $ambiguity;
571 }
572
573 return $preview;
574 }
575
576 /**
577 * Whether the requested slot is free, for the preview only.
578 *
579 * Advisory: the slot is claimed and re-checked under a lock at execute
580 * time, so true means "free a moment ago", never a reservation. null when
581 * the engine could not answer.
582 *
583 * @return bool|null
584 */
585 private static function previewSlotAvailability(CalendarSlot $event, $params, $timezone)
586 {
587 $startTime = sanitize_text_field(Arr::get($params, 'start_time', ''));
588
589 if (!$startTime) {
590 return null;
591 }
592
593 try {
594 $startUtc = MCPHelper::toUtc($startTime, $timezone);
595
596 $check = SlotResolver::checkSlot(
597 $event,
598 $startUtc,
599 $timezone,
600 Arr::get($params, 'duration'),
601 absint(Arr::get($params, 'host_id')) ?: null
602 );
603 } catch (\Throwable $e) {
604 return null;
605 }
606
607 if (is_wp_error($check) || !isset($check['available'])) {
608 return null;
609 }
610
611 return (bool) $check['available'];
612 }
613
614 /**
615 * @return array|\WP_Error
616 */
617 private static function previewAction(Booking $booking, $action, $params, $timezone)
618 {
619 // The same state-machine check the execute runs. Without it a dry run
620 // previewed `no_show -> cancelled` and minted a confirm_token for a
621 // call the execute would refuse.
622 $transitions = BookingWriter::statusTransitions();
623
624 if (isset($transitions[$action])) {
625 $target = $transitions[$action]['to'];
626
627 if ($booking->status === $target) {
628 return MCPHelper::error(
629 'no_change',
630 /* translators: %s: the booking's current status */
631 sprintf(__('This booking is already "%s".', 'fluent-booking'), $target),
632 ['status' => $booking->status]
633 );
634 }
635
636 if (in_array($action, ['complete', 'no_show'], true) && $booking->end_time > gmdate('Y-m-d H:i:s')) { // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
637 return MCPHelper::error(
638 'not_yet_occurred',
639 /* translators: %s: the requested status */
640 sprintf(__('This booking has not happened yet, so it cannot be marked "%s". Cancel it instead, or wait until it has ended.', 'fluent-booking'), $target),
641 ['status' => $booking->status, 'ends_at' => $booking->end_time]
642 );
643 }
644
645 if (!in_array($booking->status, $transitions[$action]['from'], true)) {
646 return MCPHelper::error(
647 'invalid_transition',
648 sprintf(
649 /* translators: %1$s: current status, %2$s: requested status, %3$s: allowed statuses */
650 __('A booking that is "%1$s" cannot become "%2$s". Only %3$s bookings can.', 'fluent-booking'),
651 $booking->status,
652 $target,
653 implode(', ', $transitions[$action]['from'])
654 ),
655 ['status' => $booking->status, 'allowed_from' => $transitions[$action]['from']]
656 );
657 }
658 }
659
660 $notify = BookingWriter::wantsNotifications($params);
661
662 $preview = [
663 'action' => $action,
664 'booking' => BookingProjector::row($booking, $timezone),
665 'notifications_requested' => $notify,
666 ];
667
668 if ($action === 'reschedule') {
669 $preview['from'] = MCPHelper::timePair($booking->start_time, $timezone, 'current_start');
670 $preview['to'] = sanitize_text_field(Arr::get($params, 'start_time', ''));
671
672 if (!$preview['to']) {
673 return MCPHelper::error('missing_start_time', __('reschedule needs start_time.', 'fluent-booking'));
674 }
675
676 $preview['note'] = __('Availability is re-checked when you execute.', 'fluent-booking');
677
678 if ($ambiguity = MCPHelper::ambiguityNote($preview['to'], $timezone)) {
679 $preview['timezone_warning'] = $ambiguity;
680 }
681 }
682
683 if (in_array($action, ['cancel', 'reject'], true)) {
684 $preview['reason'] = sanitize_text_field(Arr::get($params, 'reason', ''));
685 $preview['refund_payment'] = Arr::isTrue($params, 'refund_payment');
686
687 if ($booking->payment_method && !$preview['refund_payment']) {
688 $preview['payment_note'] = __('This booking was paid for. No refund will be issued unless you pass refund_payment.', 'fluent-booking');
689 }
690 }
691
692 if ($action === 'update_details') {
693 $fields = (array) Arr::get($params, 'fields', []);
694
695 if (!$fields) {
696 return MCPHelper::error('missing_fields', __('update_details needs fields.', 'fluent-booking'));
697 }
698
699 $preview['changing'] = array_keys($fields);
700
701 if (array_key_exists('email', $fields)) {
702 $preview['email_change'] = [
703 'from' => MCPHelper::maskEmail($booking->email),
704 'to' => MCPHelper::maskEmail(Arr::get($fields, 'email')),
705 ];
706 $preview['warning'] = __('Changing the address redirects every future email for this booking, the join link included. The current attendee is not notified that it moved.', 'fluent-booking');
707 }
708 }
709
710 if ($action === 'confirm' && $booking->payment_method && $booking->payment_status !== 'paid') {
711 $preview['payment_effect'] = __('This settles the booking\'s order: it is marked fully paid and the payment-completed hooks fire. No money is captured — the record is simply treated as settled.', 'fluent-booking');
712 }
713
714 $transitions = BookingWriter::statusTransitions();
715
716 if (isset($transitions[$action])) {
717 $preview['status_change'] = [
718 'from' => $booking->status,
719 'to' => $transitions[$action]['to'],
720 ];
721 }
722
723 if ($notify && $booking->calendar_event) {
724 $preview['will_notify'] = self::recipientSummary($booking->calendar_event, $booking);
725 }
726
727 return $preview;
728 }
729
730 /**
731 * Who an action would email, described rather than enumerated — the exact
732 * template that fires depends on the event type's notification settings,
733 * and listing every address would leak contact details into a preview an
734 * agent may echo back verbatim.
735 *
736 * @return array
737 */
738 private static function recipientSummary(CalendarSlot $event, $booking = null)
739 {
740 $recipients = [];
741
742 $notifications = $event->getNotifications();
743
744 if (Arr::isTrue($notifications, 'booking_conf_attendee.enabled') || Arr::isTrue($notifications, 'booking_request_attendee.enabled')) {
745 $recipients[] = $booking
746 ? sprintf('attendee (%s)', MCPHelper::maskEmail($booking->email))
747 : 'attendee';
748 }
749
750 if (Arr::isTrue($notifications, 'booking_conf_host.enabled') || Arr::isTrue($notifications, 'booking_request_host.enabled')) {
751 $recipients[] = 'host';
752 }
753
754 return $recipients;
755 }
756
757 /**
758 * A create has no prior state to go stale, but the event type's own
759 * configuration does — an event deactivated or re-timed between preview and
760 * execute should invalidate the token rather than silently book against the
761 * old shape.
762 *
763 * @return string
764 */
765 private static function createFingerprint(CalendarSlot $event)
766 {
767 return implode('|', [$event->id, $event->status, $event->duration, $event->updated_at]);
768 }
769 }
770