PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Modules / MCP / Tools / BookingWriteTools.php

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

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