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 / Support / BookingWriter.php

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

1,509 lines 60.4 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\Support;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\CalendarSlot;
7 use FluentBooking\App\Services\BookingFieldService;
8 use FluentBooking\App\Services\BookingService;
9 use FluentBooking\App\Services\EmailNotificationService;
10 use FluentBooking\App\Services\Helper;
11 use FluentBooking\App\Services\NotificationGate;
12 use FluentBooking\App\Services\PermissionManager;
13 use FluentBooking\App\Services\RescheduleService;
14 use FluentBooking\App\Hooks\Handlers\TimeSlotServiceHandler;
15 use FluentBooking\Framework\Support\Arr;
16
17 defined('ABSPATH') || exit;
18
19 /**
20 * Every mutation the MCP server performs on a booking, in one place.
21 *
22 * The rule this class exists to enforce: an agent's write must be
23 * indistinguishable from the same write done by a human in wp-admin. Same
24 * validation, same status transitions, same hooks — so remote calendars sync,
25 * CRM triggers fire, webhooks deliver, and payment side effects happen exactly
26 * as they would otherwise. Where the plugin already has a service for the job
27 * (BookingService::createBooking, RescheduleService::reschedule,
28 * Booking::cancelMeeting) we call it rather than reimplementing it; the drift
29 * risk of a second implementation is not worth the convenience.
30 *
31 * The one thing MCP writes do that admin writes do not: every one of them lands
32 * an activity row tagged with the acting user and `via MCP`, so an operator
33 * reading a booking's timeline can always tell an agent's action from a
34 * human's.
35 *
36 * @since 2.2.6
37 */
38 class BookingWriter
39 {
40 /**
41 * Columns manage-booking's `update_details` may write, mirroring
42 * SchedulesController::patchBooking()'s whitelist minus the two that have
43 * their own actions (status, payment_status).
44 */
45 const EDITABLE_FIELDS = ['first_name', 'last_name', 'email', 'phone', 'internal_note'];
46
47 /**
48 * @var array guests the current create() was asked for and did not book
49 */
50 private static $guestsDropped = [];
51
52 /**
53 * Statuses a booking can move to, and what each one is allowed to move from.
54 * Mirrors Booking::cancelMeeting()/rejectMeeting() and the admin's own
55 * transitions so an agent cannot reach a state the UI would refuse.
56 */
57 public static function statusTransitions()
58 {
59 return [
60 'cancel' => ['to' => 'cancelled', 'from' => ['scheduled', 'pending']],
61 'reject' => ['to' => 'rejected', 'from' => ['pending']],
62 'confirm' => ['to' => 'scheduled', 'from' => ['pending']],
63 'complete' => ['to' => 'completed', 'from' => ['scheduled', 'rescheduled']],
64 'no_show' => ['to' => 'no_show', 'from' => ['scheduled', 'rescheduled', 'completed']],
65 ];
66 }
67
68 /**
69 * Create a booking on an attendee's behalf.
70 *
71 * Deliberately mirrors BookingController::createBooking(): resolve the
72 * duration, convert the requested wall-clock time to UTC, resolve the
73 * location from the event's own configured locations, assign a round-robin
74 * host, re-check availability against the live slot engine, then hand off to
75 * BookingService so every downstream integration behaves normally.
76 *
77 * @param CalendarSlot $event
78 * @param array $params
79 *
80 * @return Booking|\WP_Error
81 */
82 public static function create(CalendarSlot $event, $params)
83 {
84 if ($event->status !== 'active') {
85 return MCPHelper::error(
86 'event_not_bookable',
87 /* translators: %s: the event type's current status */
88 sprintf(__('This event type is "%s", not active, so it is not accepting bookings.', 'fluent-booking'), $event->status),
89 ['event_status' => $event->status]
90 );
91 }
92
93 $email = sanitize_email(Arr::get($params, 'email', ''));
94
95 if (!$email || !is_email($email)) {
96 return MCPHelper::error('invalid_email', __('A valid attendee email is required.', 'fluent-booking'));
97 }
98
99 $name = sanitize_text_field(Arr::get($params, 'name', ''));
100
101 if (!$name) {
102 return MCPHelper::error('missing_name', __('The attendee name is required.', 'fluent-booking'));
103 }
104
105 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
106 $duration = $event->getDuration(Arr::get($params, 'duration'));
107
108 $startTime = MCPHelper::toUtc(Arr::get($params, 'start_time', ''), $timezone);
109
110 if (is_wp_error($startTime)) {
111 return $startTime;
112 }
113
114 $endTime = gmdate('Y-m-d H:i:s', strtotime($startTime) + ($duration * 60)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
115
116 $hostUserId = self::resolveHostId($event, $params);
117
118 if (is_wp_error($hostUserId)) {
119 return $hostUserId;
120 }
121
122 $bookingData = [
123 'person_time_zone' => $timezone,
124 'start_time' => $startTime,
125 'end_time' => $endTime,
126 'name' => $name,
127 'email' => $email,
128 'message' => sanitize_textarea_field(Arr::get($params, 'message', '')),
129 'phone' => sanitize_text_field(Arr::get($params, 'phone', '')),
130 'status' => $event->isConfirmationEnabled() ? 'pending' : 'scheduled',
131 // Not 'admin': the admin source drives UI affordances that assume a
132 // human filled the form. An agent-created booking is its own thing
133 // and reporting should be able to tell them apart.
134 'source' => 'mcp',
135 'event_type' => $event->event_type,
136 'slot_minutes' => $duration,
137 ];
138
139 if ($internalNote = Arr::get($params, 'internal_note')) {
140 $bookingData['internal_note'] = sanitize_textarea_field($internalNote);
141 }
142
143 $location = self::resolveLocation($event, $params);
144
145 if (is_wp_error($location)) {
146 return $location;
147 }
148
149 $bookingData['location_details'] = $location;
150
151 if (Arr::get($location, 'type') === 'phone_guest' && empty($bookingData['phone'])) {
152 $bookingData['phone'] = Arr::get($location, 'description', '');
153 }
154
155 $customFieldsData = BookingFieldService::getCustomFieldsData(
156 (array) Arr::get($params, 'custom_fields', []),
157 $event
158 );
159
160 if (is_wp_error($customFieldsData)) {
161 return MCPHelper::error(
162 'invalid_custom_fields',
163 $customFieldsData->get_error_message(),
164 ['errors' => $customFieldsData->get_error_data()]
165 );
166 }
167
168 // Round robin picks the host the public page would have picked, so the
169 // agent's booking lands on the same person a self-service booking would.
170 if ($event->isRoundRobin() && !$hostUserId) {
171 $sortedHostIds = $event->getHostIdsSortedByBookings($startTime);
172 $bookingData['host_user_id'] = $sortedHostIds[0];
173 } elseif ($hostUserId) {
174 $bookingData['host_user_id'] = $hostUserId;
175 }
176
177 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
178
179 if (is_wp_error($service)) {
180 return MCPHelper::error('slot_service_unavailable', $service->get_error_message());
181 }
182
183 // Hold the slot for the duration of the check-then-write. Availability
184 // is computed by a query and the booking is a separate INSERT, so
185 // without this two agents that both pass isSpotAvailable() before either
186 // writes will both write — the "re-checked at execute time" guarantee
187 // narrows the race, it does not remove it. An agent can fire these far
188 // faster than a human clicking through a booking page, and MCP hands the
189 // same slot to whoever asks first.
190 // Every host the booking would occupy, so two event types sharing an
191 // owner cannot both write. getHostIds() is the event's own answer: one
192 // id for a single or group event, all of them for a collective.
193 // Round robin is the exception — its host is chosen inside
194 // isSpotAvailable(), so it locks the event first and the host below.
195 $lock = self::lockSlot($event, $startTime, $endTime, $hostUserId);
196 $hostLock = false;
197
198 if (!$lock) {
199 return MCPHelper::error(
200 'slot_locked',
201 __('Another booking for this slot is being created right now. Wait a moment and check get-available-slots before retrying.', 'fluent-booking'),
202 ['requested_start' => $startTime]
203 );
204 }
205
206 try {
207 $availableSpot = $service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId);
208
209 if (!$availableSpot) {
210 return MCPHelper::error(
211 'slot_unavailable',
212 __('That time is not available. Call get-available-slots for the current openings, or diagnose-availability to find out why the calendar is closed.', 'fluent-booking'),
213 [
214 'requested_start' => $startTime,
215 'next_step' => 'call fluent-booking/get-available-slots',
216 ]
217 );
218 }
219
220 // isSpotAvailable() can outrun the lease on a team event with a
221 // cold calendar cache, and an expired lease is stolen without
222 // question — so re-assert it before writing rather than trusting
223 // that the lock taken above is still ours.
224 if (!SlotLock::renewAll($lock)) {
225 return MCPHelper::error(
226 'slot_locked',
227 __('Another booking for this slot was created while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
228 ['requested_start' => $startTime]
229 );
230 }
231
232 if ($event->isRoundRobin() && !$hostUserId && !empty($service->hostUserId)) {
233 $hostUserId = (int) $service->hostUserId;
234
235 $bookingData['host_user_id'] = $hostUserId;
236
237 // The first lock could only name the event: round robin has no
238 // host until the line above settles one. Claim that host now.
239 $hostLock = SlotLock::acquireInterval($event->id, $startTime, $endTime, [$hostUserId]);
240
241 if (!$hostLock) {
242 return MCPHelper::error(
243 'slot_locked',
244 __('That host was booked for this slot while this request was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
245 ['requested_start' => $startTime]
246 );
247 }
248
249 // The check above settled this host while only the event was
250 // locked, so another event type could have taken the person in
251 // between. Re-check under the host lock.
252 $availableSpot = $service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId);
253
254 if (!$availableSpot) {
255 return MCPHelper::error(
256 'slot_unavailable',
257 __('That host was booked for this slot while this request was being checked. Call get-available-slots for the current openings.', 'fluent-booking'),
258 [
259 'requested_start' => $startTime,
260 'next_step' => 'call fluent-booking/get-available-slots',
261 ]
262 );
263 }
264
265 if (!SlotLock::renewAll($lock) || !SlotLock::renewAll($hostLock)) {
266 return MCPHelper::error(
267 'slot_locked',
268 __('Another booking for this slot was created while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
269 ['requested_start' => $startTime]
270 );
271 }
272 }
273
274 self::$guestsDropped = [];
275
276 if ($guests = self::sanitizeGuests($params, $event, $availableSpot)) {
277 $bookingData['additional_guests'] = $guests;
278 }
279
280 $notify = self::wantsNotifications($params);
281
282 $create = function () use ($bookingData, $event, $customFieldsData) {
283 return BookingService::createBooking($bookingData, $event, $customFieldsData);
284 };
285
286 $booking = $notify ? $create() : NotificationGate::silently($create);
287 } catch (\Throwable $e) {
288 // Not $e->getMessage(): an ORM or PDO failure carries table names,
289 // SQL fragments and absolute paths, and returning it here would walk
290 // straight past the scrubbing AbilitiesRegistrar does for exactly
291 // this reason. Catching Throwable rather than Exception also means a
292 // TypeError from a downstream service is handled the same way.
293 self::logException('create-booking', $e);
294
295 return MCPHelper::error(
296 'booking_failed',
297 __('The booking could not be created. The site logged the details.', 'fluent-booking')
298 );
299 } finally {
300 // Every exit from the block above releases the slot, the early
301 // returns included — a lock left behind would block the slot for its
302 // whole TTL after a failure that changed nothing.
303 SlotLock::releaseAll($lock);
304 SlotLock::releaseAll($hostLock);
305 }
306
307 if (is_wp_error($booking)) {
308 return MCPHelper::error('booking_failed', $booking->get_error_message());
309 }
310
311 if (!$booking instanceof Booking) {
312 return MCPHelper::error('booking_failed', __('The booking could not be created.', 'fluent-booking'));
313 }
314
315 self::logActivity(
316 $booking,
317 __('Booking Created via MCP', 'fluent-booking'),
318 /* translators: %1$s: acting user, %2$s: booking start time in UTC */
319 sprintf(__('Booking created by %1$s through the MCP server for %2$s (UTC).', 'fluent-booking'), self::actorName(), $booking->start_time),
320 $notify
321 );
322
323 return $booking;
324 }
325
326 /**
327 * Move a booking to a new time. Delegates to the same RescheduleService the
328 * public booking form uses, so the two can never disagree about group
329 * re-assignment, round-robin hosts or which emails go out.
330 *
331 * @param Booking $booking
332 * @param array $params
333 *
334 * @return Booking|\WP_Error
335 */
336 public static function reschedule(Booking $booking, $params)
337 {
338 $event = $booking->calendar_event;
339
340 if (!$event) {
341 return MCPHelper::error('event_missing', __('This booking has no event type, so it cannot be rescheduled.', 'fluent-booking'));
342 }
343
344 if (!in_array($booking->status, ['scheduled', 'pending', 'rescheduled'], true)) {
345 return MCPHelper::error(
346 'not_reschedulable',
347 /* translators: %s: the booking's current status */
348 sprintf(__('This booking is "%s" and cannot be rescheduled. Create a new booking instead.', 'fluent-booking'), $booking->status),
349 ['status' => $booking->status]
350 );
351 }
352
353 // Fall back to the ATTENDEE's zone, not the site's. This used to read
354 // `resolveTimezone(...) ?: $booking->person_time_zone`, and
355 // resolveTimezone() never returns anything falsy — its last line is
356 // `return 'UTC'` — so the fallback was unreachable and an omitted
357 // timezone silently meant "site time". For an attendee in Tokyo that
358 // moved the meeting and then overwrote their stored zone with the
359 // site's on the way out.
360 $requested = trim((string) Arr::get($params, 'timezone', ''));
361
362 $timezone = $requested
363 ? MCPHelper::resolveTimezone($requested)
364 : MCPHelper::resolveTimezone($booking->person_time_zone);
365
366 $startTime = MCPHelper::toUtc(Arr::get($params, 'start_time', ''), $timezone);
367
368 if (is_wp_error($startTime)) {
369 return $startTime;
370 }
371
372 $duration = $booking->slot_minutes;
373 $endTime = gmdate('Y-m-d H:i:s', strtotime($startTime) + ($duration * 60)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
374
375 $hostUserId = self::resolveHostId($event, $params);
376
377 if (is_wp_error($hostUserId)) {
378 return $hostUserId;
379 }
380
381 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
382
383 if (is_wp_error($service)) {
384 return MCPHelper::error('slot_service_unavailable', $service->get_error_message());
385 }
386
387 // Same check-then-write race as create(), and the same hold over it,
388 // round-robin key included.
389 $lock = self::lockSlot($event, $startTime, $endTime, $hostUserId);
390 $hostLock = false;
391
392 if (!$lock) {
393 return MCPHelper::error(
394 'slot_locked',
395 __('Another booking for the target slot is being written right now. Wait a moment and check get-available-slots before retrying.', 'fluent-booking'),
396 ['requested_start' => $startTime]
397 );
398 }
399
400 try {
401 // Re-check at execute time, not just at preview time — the slot may
402 // have been taken during the confirm round-trip.
403 if (!$service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId)) {
404 return MCPHelper::error(
405 'slot_unavailable',
406 __('That time is no longer available. Call get-available-slots for the current openings.', 'fluent-booking'),
407 [
408 'requested_start' => $startTime,
409 'next_step' => 'call fluent-booking/get-available-slots',
410 ]
411 );
412 }
413
414 // Same reason as create(): isSpotAvailable() can outrun the lease,
415 // and an expired one is stolen without question.
416 if (!SlotLock::renewAll($lock)) {
417 return MCPHelper::error(
418 'slot_locked',
419 __('Another booking for the target slot was written while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
420 ['requested_start' => $startTime]
421 );
422 }
423
424 if ($event->isRoundRobin() && !$hostUserId && !empty($service->hostUserId)) {
425 $hostUserId = (int) $service->hostUserId;
426
427 $hostLock = SlotLock::acquireInterval($event->id, $startTime, $endTime, [$hostUserId]);
428
429 if (!$hostLock) {
430 return MCPHelper::error(
431 'slot_locked',
432 __('That host was booked for the target slot while this request was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
433 ['requested_start' => $startTime]
434 );
435 }
436
437 // Same race as create(): re-check under the host lock.
438 if (!$service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId)) {
439 return MCPHelper::error(
440 'slot_unavailable',
441 __('That host was booked for the target slot while this request was being checked. Call get-available-slots for the current openings.', 'fluent-booking'),
442 [
443 'requested_start' => $startTime,
444 'next_step' => 'call fluent-booking/get-available-slots',
445 ]
446 );
447 }
448
449 if (!SlotLock::renewAll($lock) || !SlotLock::renewAll($hostLock)) {
450 return MCPHelper::error(
451 'slot_locked',
452 __('Another booking for the target slot was written while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
453 ['requested_start' => $startTime]
454 );
455 }
456 }
457
458 $notify = self::wantsNotifications($params);
459
460 $move = function () use ($booking, $event, $startTime, $timezone, $params, $hostUserId) {
461 return RescheduleService::reschedule($booking, $event, $startTime, $timezone, [
462 'reason' => Arr::get($params, 'reason', ''),
463 'host_user_id' => $hostUserId,
464 'source' => __('the MCP server', 'fluent-booking'),
465 // An agent always acts for the host; it holds host
466 // credentials, not the attendee's booking link, so the
467 // guest-side reschedule window must not apply to it.
468 'rescheduled_by' => 'host',
469 ]);
470 };
471
472 $result = $notify ? $move() : NotificationGate::silently($move);
473 } catch (\Throwable $e) {
474 self::logException('reschedule', $e);
475
476 return MCPHelper::error(
477 'reschedule_failed',
478 __('The booking could not be moved. The site logged the details.', 'fluent-booking')
479 );
480 } finally {
481 SlotLock::releaseAll($lock);
482 SlotLock::releaseAll($hostLock);
483 }
484
485 if (is_wp_error($result)) {
486 return MCPHelper::error('reschedule_failed', $result->get_error_message());
487 }
488
489 // RescheduleService writes its own row, but it records the ROLE ("by
490 // host") rather than the person. Every other MCP write names the
491 // operator, and reschedule is the one most worth attributing.
492 self::logActivity(
493 $result,
494 __('Booking Rescheduled via MCP', 'fluent-booking'),
495 /* translators: %1$s: acting user, %2$s: the new start time in UTC */
496 sprintf(__('%1$s moved this booking to %2$s (UTC) through the MCP server.', 'fluent-booking'), self::actorName(), $startTime),
497 $notify
498 );
499
500 return $result;
501 }
502
503 /**
504 * Apply a status transition, reproducing SchedulesController::patchBooking()
505 * including its payment side effects and its hook sequence.
506 *
507 * @param Booking $booking
508 * @param string $action
509 * @param array $params
510 *
511 * @return Booking|\WP_Error
512 */
513 public static function applyStatus(Booking $booking, $action, $params)
514 {
515 $transitions = self::statusTransitions();
516
517 if (!isset($transitions[$action])) {
518 return MCPHelper::error('unsupported_action', __('That action is not a status change.', 'fluent-booking'));
519 }
520
521 $target = $transitions[$action]['to'];
522 $from = $transitions[$action]['from'];
523
524 if ($booking->status === $target) {
525 return MCPHelper::error(
526 'no_change',
527 /* translators: %s: the booking's current status */
528 sprintf(__('This booking is already "%s".', 'fluent-booking'), $target),
529 ['status' => $booking->status]
530 );
531 }
532
533 // Both say a meeting already happened. Marking one three weeks out as
534 // completed reads to every report as a meeting that took place.
535 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
536 return MCPHelper::error(
537 'not_yet_occurred',
538 /* translators: %s: the requested status */
539 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),
540 ['status' => $booking->status, 'ends_at' => $booking->end_time]
541 );
542 }
543
544 if (!in_array($booking->status, $from, true)) {
545 return MCPHelper::error(
546 'invalid_transition',
547 sprintf(
548 /* translators: %1$s: current status, %2$s: requested status, %3$s: allowed statuses */
549 __('A booking that is "%1$s" cannot become "%2$s". Only %3$s bookings can.', 'fluent-booking'),
550 $booking->status,
551 $target,
552 implode(', ', $from)
553 ),
554 ['status' => $booking->status, 'allowed_from' => $from]
555 );
556 }
557
558 $reason = sanitize_text_field(Arr::get($params, 'reason', ''));
559 $notify = self::wantsNotifications($params);
560
561 $apply = function () use ($booking, $action, $target, $reason, $params) {
562 return self::transition($booking, $action, $target, $reason, $params);
563 };
564
565 $result = $notify ? $apply() : NotificationGate::silently($apply);
566
567 if (is_wp_error($result)) {
568 return $result;
569 }
570
571 self::logActivity(
572 $booking,
573 /* translators: %s: the new booking status */
574 sprintf(__('Status changed to %s via MCP', 'fluent-booking'), $target),
575 /* translators: %1$s: acting user, %2$s: new status */
576 sprintf(__('%1$s set this booking to "%2$s" through the MCP server.', 'fluent-booking'), self::actorName(), $target),
577 $notify
578 );
579
580 return Booking::with(['calendar_event'])->find($booking->id);
581 }
582
583 /**
584 * The transition itself. Cancel and reject go through the model methods so
585 * their reason handling, activity rows and hooks stay in one place; the
586 * others mirror patchBooking()'s own sequence.
587 *
588 * @return true|\WP_Error
589 */
590 private static function transition(Booking $booking, $action, $target, $reason, $params)
591 {
592 $from = self::statusTransitions()[$action]['from'];
593
594 if ($action === 'cancel' || $action === 'reject') {
595 // Re-read immediately before mutating so the model's own status
596 // guard runs against current data rather than whatever was loaded
597 // when the request started. An agent can fire these far faster than
598 // a human clicking in wp-admin, so the read-to-write window matters
599 // here in a way it does not there.
600 $fresh = Booking::find($booking->id);
601
602 if (!$fresh || !in_array($fresh->status, $from, true)) {
603 return MCPHelper::error(
604 'state_changed',
605 __('This booking changed while the request was in flight and is no longer in a state that allows this action.', 'fluent-booking'),
606 ['status' => $fresh ? $fresh->status : null]
607 );
608 }
609
610 $priorStatus = $fresh->status;
611
612 // The re-read above narrows the window; it does not close it. Two
613 // hosts cancelling the same collective booking both see `scheduled`
614 // and both proceed — and with refund_payment set, both fire the
615 // gateway's refund hook. So claim the transition atomically first,
616 // exactly as the non-cancel branch below does, and only let the
617 // winner run the side effects. cancelMeeting()/rejectMeeting() then
618 // do their own work on a row we already own.
619 $claimed = Booking::where('id', $fresh->id)
620 ->whereIn('status', $from)
621 ->update(['status' => $target]);
622
623 if (!$claimed) {
624 return MCPHelper::error(
625 'state_changed',
626 __('This booking changed while the request was in flight and is no longer in a state that allows this action.', 'fluent-booking'),
627 ['status' => Booking::where('id', $fresh->id)->value('status')]
628 );
629 }
630
631 // Hand the model back the status it actually held — not $from[0] —
632 // so cancelMeeting() runs its normal transition instead of
633 // short-circuiting on "already cancelled", and so anything keyed on
634 // the prior status (pending vs scheduled) still sees the truth.
635 $fresh->status = $priorStatus;
636
637 // The claim above moved the persisted status ahead of the work that
638 // gives it meaning — the reason, the activity row, the hooks, the
639 // notification, the refund. A failure before any of it must put the
640 // row back. A failure after must not: the hook cancelMeeting() and
641 // rejectMeeting() fire mails the attendee and deletes the remote
642 // calendar event, so reverting there leaves a live booking whose
643 // attendee holds a cancellation. This marks which side it fell on.
644 $notified = false;
645
646 $marker = function () use (&$notified) {
647 $notified = true;
648 };
649
650 $hook = $action === 'cancel'
651 ? 'fluent_booking/booking_schedule_cancelled'
652 : 'fluent_booking/booking_schedule_rejected';
653
654 add_action($hook, $marker, PHP_INT_MIN);
655
656 try {
657 if ($action === 'cancel') {
658 $result = $fresh->cancelMeeting($reason, 'host', get_current_user_id());
659
660 if (is_wp_error($result)) {
661 self::rollbackStatus($fresh->id, $priorStatus, $target);
662
663 return $result;
664 }
665 } else {
666 $fresh->rejectMeeting($reason, get_current_user_id());
667 }
668 } catch (\Throwable $e) {
669 self::logException('manage-booking:' . $action, $e);
670
671 if (!$notified) {
672 self::rollbackStatus($fresh->id, $priorStatus, $target);
673
674 return MCPHelper::error(
675 'transition_failed',
676 __('The change could not be completed and the booking was left as it was. The site logged the details.', 'fluent-booking')
677 );
678 }
679
680 return self::partiallyCompleted($fresh->id, $target);
681 } finally {
682 remove_action($hook, $marker, PHP_INT_MIN);
683 }
684
685 // The refund runs after the cancellation is already out. Failing
686 // to move the money is not a reason to un-cancel.
687 try {
688 self::maybeRefund($fresh, $params);
689 } catch (\Throwable $e) {
690 self::logException('manage-booking:' . $action . ':refund', $e);
691
692 return MCPHelper::error(
693 'refund_failed',
694 __('The booking is cancelled but the refund did not go through. Issue it in the payment gateway.', 'fluent-booking'),
695 ['booking_id' => $fresh->id, 'status' => $target]
696 );
697 }
698
699 return true;
700 }
701
702 // Same read-then-claim as the cancel branch: the claim reports success,
703 // not which of the allowed statuses the row actually held, and a
704 // rollback needs the real one.
705 $priorStatus = Booking::where('id', $booking->id)->value('status');
706
707 // Compare-and-set. Two agents racing the same transition both pass the
708 // in-memory status check; only the one whose UPDATE matches a row still
709 // in an allowed status gets to fire the side effects.
710 $claimed = Booking::where('id', $booking->id)
711 ->whereIn('status', $from)
712 ->update(['status' => $target]);
713
714 if (!$claimed) {
715 return MCPHelper::error(
716 'state_changed',
717 __('This booking changed while the request was in flight and is no longer in a state that allows this action.', 'fluent-booking'),
718 ['status' => Booking::where('id', $booking->id)->value('status')]
719 );
720 }
721
722 $booking->status = $target;
723
724 $notified = false;
725
726 // The claim moved the persisted status ahead of the work that gives it
727 // meaning — the order, the payment status, the activity row, the hooks.
728 // Cancel and reject already put the row back when that work fails; this
729 // branch makes the same claim, so it owes the same guarantee.
730 try {
731 // Confirming a booking that was paid for settles its order, exactly
732 // as the admin's confirm does.
733 if ($action === 'confirm' && $booking->payment_method && $booking->payment_order) {
734 $settled = self::settlePayment($booking, $booking->payment_order);
735
736 if (is_wp_error($settled)) {
737 self::rollbackStatus($booking->id, $priorStatus, $target);
738
739 return $settled;
740 }
741
742 // The admin's confirm writes this row too. Payment reporting
743 // reads the activity trail, so skipping it would make an
744 // agent-confirmed payment look like it never settled.
745 do_action('fluent_booking/log_booking_activity', [
746 'booking_id' => $booking->id,
747 'status' => 'closed',
748 'type' => 'success',
749 'title' => __('Payment Successfully Completed', 'fluent-booking'),
750 /* translators: %s: the user who confirmed the booking */
751 'description' => sprintf(__('Payment marked as paid by %s through the MCP server.', 'fluent-booking'), self::actorName()),
752 ]);
753
754 do_action('fluent_booking/payment/update_payment_status_paid', $booking);
755 }
756
757 // Same commit point as the cancel branch.
758 $notified = true;
759
760 do_action('fluent_booking/booking_schedule_' . $target, $booking, $booking->calendar_event);
761
762 do_action('fluent_booking/pre_after_booking_' . $target, $booking, $booking->calendar_event);
763
764 $fresh = Booking::with(['calendar_event', 'calendar'])->find($booking->id);
765
766 do_action('fluent_booking/after_booking_' . $target, $fresh, $fresh->calendar_event, $fresh);
767 } catch (\Throwable $e) {
768 self::logException('manage-booking:' . $action, $e);
769
770 if ($notified) {
771 return self::partiallyCompleted($booking->id, $target);
772 }
773
774 // Payment state is left as it is. A booking that is paid for but
775 // still awaiting approval is a normal state here, not a broken one:
776 // it is exactly where a gateway leaves a booking on an event that
777 // requires confirmation. Rolling the order back would invent a
778 // refund that never happened. Only the status claim is undone.
779 self::rollbackStatus($booking->id, $priorStatus, $target);
780
781 return MCPHelper::error(
782 'transition_failed',
783 __('The change could not be completed and the booking was left as it was. The site logged the details.', 'fluent-booking')
784 );
785 }
786
787 return true;
788 }
789
790 /**
791 * Mark an order paid and the booking with it, as one commit.
792 *
793 * The two rows state the same fact, and they were written in sequence: a
794 * failure between them left the order settled while the booking still read
795 * unpaid, which is a divergence no later call reconciles.
796 *
797 * Only the two writes are inside the transaction. The hooks stay outside
798 * deliberately — a listener that makes an outbound request would otherwise
799 * hold both row locks for the length of someone else's HTTP call.
800 *
801 * @param Booking $booking
802 * @param object $order
803 *
804 * @return true|\WP_Error
805 */
806 private static function settlePayment(Booking $booking, $order)
807 {
808 try {
809 Helper::dbTransaction(function () use ($booking, $order) {
810 $order->total_paid = $order->total_amount;
811 $order->completed_at = gmdate('Y-m-d H:i:s'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
812 $order->status = 'paid';
813 $order->save();
814
815 $booking->payment_status = 'paid';
816 $booking->save();
817 });
818 } catch (\Throwable $e) {
819 self::logException('manage-booking:confirm:payment', $e);
820
821 return MCPHelper::error(
822 'payment_settlement_failed',
823 __('The booking was left as it was: its payment could not be settled. The site logged the details.', 'fluent-booking')
824 );
825 }
826
827 return true;
828 }
829
830 /**
831 * Claim the slot for every host this booking would occupy.
832 *
833 * @param CalendarSlot $event
834 * @param string $startTime
835 * @param string $endTime
836 * @param int|null $hostUserId a host the caller pinned, if any
837 *
838 * @return array|false
839 */
840 private static function lockSlot(CalendarSlot $event, $startTime, $endTime, $hostUserId)
841 {
842 if ($event->isRoundRobin()) {
843 $lock = SlotLock::acquire($event->id, $startTime, null);
844
845 return $lock ? [$lock] : false;
846 }
847
848 $hosts = $hostUserId ? [$hostUserId] : (array) $event->getHostIds();
849
850 if (!$hosts) {
851 $lock = SlotLock::acquire($event->id, $startTime, null);
852
853 return $lock ? [$lock] : false;
854 }
855
856 return SlotLock::acquireInterval($event->id, $startTime, $endTime, $hosts);
857 }
858
859 /**
860 * Undo a claimed status transition whose side effects did not complete.
861 *
862 * Conditional on the row still holding the status we claimed: if something
863 * downstream already moved it on, that later state is the current truth and
864 * stamping the old one back over it would be its own corruption.
865 *
866 * @param int $bookingId
867 * @param string $priorStatus
868 * @param string $claimedStatus
869 */
870 private static function rollbackStatus($bookingId, $priorStatus, $claimedStatus)
871 {
872 Booking::where('id', $bookingId)
873 ->where('status', $claimedStatus)
874 ->update(['status' => $priorStatus]);
875 }
876
877 /**
878 * A failure after the attendee was notified. Distinct from
879 * `transition_failed`, which tells the agent nothing happened.
880 *
881 * @param int $bookingId
882 * @param string $status
883 *
884 * @return \WP_Error
885 */
886 private static function partiallyCompleted($bookingId, $status)
887 {
888 return MCPHelper::error(
889 'partially_completed',
890 /* translators: %s: the booking's new status */
891 sprintf(__('The booking is now "%s" and the attendee has been notified, but part of the follow-up did not finish. Read the booking before acting on it again. The site logged the details.', 'fluent-booking'), $status),
892 ['booking_id' => $bookingId, 'status' => $status]
893 );
894 }
895
896 /**
897 * Cancelling or rejecting a paid booking can refund it, but only when the
898 * caller asks explicitly — an agent must never move money as a side effect
899 * of a status change.
900 */
901 private static function maybeRefund(Booking $booking, $params)
902 {
903 if (!$booking->payment_method || !Arr::isTrue($params, 'refund_payment')) {
904 return;
905 }
906
907 do_action('fluent_booking/refund_payment_' . $booking->payment_method, $booking, $booking->calendar_event);
908 }
909
910 /**
911 * Edit an attendee's details on an existing booking. Reversible, so it is
912 * not gated behind a confirm token — but it still writes an activity row.
913 *
914 * @param Booking $booking
915 * @param array $fields
916 * @param array $params
917 *
918 * @return Booking|\WP_Error
919 */
920 public static function updateDetails(Booking $booking, $fields, $params)
921 {
922 $fields = (array) $fields;
923
924 $unknown = array_diff(array_keys($fields), self::EDITABLE_FIELDS);
925
926 if ($unknown) {
927 return MCPHelper::error(
928 'unknown_field',
929 sprintf(
930 /* translators: %1$s: rejected field names, %2$s: accepted field names */
931 __('These fields cannot be updated: %1$s. Editable fields are: %2$s. Use the status actions to change a booking\'s status.', 'fluent-booking'),
932 implode(', ', $unknown),
933 implode(', ', self::EDITABLE_FIELDS)
934 )
935 );
936 }
937
938 $updates = [];
939 $changed = [];
940
941 foreach ($fields as $key => $value) {
942 if ($key === 'email') {
943 $value = sanitize_email($value);
944
945 if (!$value || !is_email($value)) {
946 return MCPHelper::error('invalid_email', __('That is not a valid email address.', 'fluent-booking'));
947 }
948 } elseif ($key === 'internal_note') {
949 $value = sanitize_textarea_field($value);
950 } else {
951 $value = sanitize_text_field($value);
952 }
953
954 if ((string) $booking->{$key} === (string) $value) {
955 continue;
956 }
957
958 $updates[$key] = $value;
959 $changed[] = $key;
960 }
961
962 if (!$updates) {
963 return MCPHelper::error('no_change', __('None of the supplied values differ from what is already stored.', 'fluent-booking'));
964 }
965
966 $notify = self::wantsNotifications($params);
967
968 $save = function () use ($booking, $updates) {
969 $before = [];
970
971 foreach ($updates as $key => $value) {
972 $before[$key] = $booking->{$key};
973 }
974
975 $booking->fill($updates);
976 $booking->save();
977
978 foreach ($updates as $key => $value) {
979 // patchBooking fires one hook per column; keeping that shape
980 // means existing listeners (the changed-email notification
981 // among them) behave identically.
982 do_action('fluent_booking/after_patch_booking_' . $key, $booking, $booking->calendar_event, $before[$key]);
983 }
984
985 return true;
986 };
987
988 $notify ? $save() : NotificationGate::silently($save);
989
990 self::logActivity(
991 $booking,
992 __('Booking Updated via MCP', 'fluent-booking'),
993 /* translators: %1$s: acting user, %2$s: the updated field names */
994 sprintf(__('%1$s updated %2$s through the MCP server.', 'fluent-booking'), self::actorName(), implode(', ', $changed)),
995 $notify
996 );
997
998 return Booking::with(['calendar_event'])->find($booking->id);
999 }
1000
1001 /**
1002 * Re-send the confirmation email. Mirrors
1003 * SchedulesController::sendConfirmationEmail().
1004 *
1005 * @param Booking $booking
1006 * @param string $emailTo 'guest' or 'host'
1007 *
1008 * @return array|\WP_Error
1009 */
1010 public static function resendEmail(Booking $booking, $emailTo, $params = [])
1011 {
1012 // The whole action is "send an email". Silently sending one after the
1013 // caller asked for silence — and after the dry run reported
1014 // notifications_requested:false — is worse than refusing.
1015 if (!self::wantsNotifications($params)) {
1016 return MCPHelper::error(
1017 'notifications_disabled',
1018 __('resend_email exists to send an email, so it cannot run with send_notifications:false. Drop that parameter, or use a different action.', 'fluent-booking')
1019 );
1020 }
1021
1022 // The template says the booking is going ahead, so sending it for a
1023 // cancelled or rejected one tells the attendee the opposite of the truth.
1024 if (!in_array($booking->status, ['scheduled', 'rescheduled', 'pending'], true)) {
1025 return MCPHelper::error(
1026 'not_resendable',
1027 /* translators: %s: the booking's current status */
1028 sprintf(__('This booking is "%s", so resending its confirmation would tell the attendee it is going ahead.', 'fluent-booking'), $booking->status),
1029 ['status' => $booking->status]
1030 );
1031 }
1032
1033 $emailTo = in_array($emailTo, ['guest', 'host'], true) ? $emailTo : 'guest';
1034
1035 if (!WriteGuard::cooldown('resend:' . $booking->id . ':' . $emailTo, 60)) {
1036 return MCPHelper::error(
1037 'resend_too_soon',
1038 __('That confirmation was resent within the last minute. Wait before sending another.', 'fluent-booking'),
1039 ['next_step' => 'wait 60 seconds']
1040 );
1041 }
1042
1043 $event = $booking->calendar_event;
1044
1045 if (!$event) {
1046 return MCPHelper::error('event_missing', __('This booking has no event type, so its notification templates cannot be resolved.', 'fluent-booking'));
1047 }
1048
1049 $notifications = $event->getNotifications();
1050
1051 $key = $emailTo === 'host' ? 'booking_conf_host' : 'booking_conf_attendee';
1052 $email = Arr::get($notifications, $key . '.email', []);
1053
1054 if (!$email) {
1055 return MCPHelper::error(
1056 'no_template',
1057 __('This event type has no confirmation email configured for that recipient.', 'fluent-booking'),
1058 ['recipient' => $emailTo]
1059 );
1060 }
1061
1062 $result = EmailNotificationService::emailOnBooked($booking, $email, $emailTo, 'scheduled', true);
1063
1064 if (!$result) {
1065 return MCPHelper::error('send_failed', __('The notification could not be sent.', 'fluent-booking'));
1066 }
1067
1068 self::logActivity(
1069 $booking,
1070 __('Confirmation Resent via MCP', 'fluent-booking'),
1071 /* translators: %1$s: acting user, %2$s: the recipient, guest or host */
1072 sprintf(__('%1$s re-sent the confirmation email to the %2$s through the MCP server.', 'fluent-booking'), self::actorName(), $emailTo),
1073 true
1074 );
1075
1076 return ['recipient' => $emailTo, 'sent' => true];
1077 }
1078
1079 /**
1080 * Whether the caller may act on this booking. Read access is not enough:
1081 * writing requires being one of the booking's hosts, or holding write
1082 * access to its calendar, or site-wide booking management.
1083 *
1084 * @param Booking $booking
1085 *
1086 * @return bool
1087 */
1088 public static function canWriteBooking(Booking $booking)
1089 {
1090 if (current_user_can('manage_options') || PermissionManager::userCan(['manage_all_data', 'manage_all_bookings'])) {
1091 return true;
1092 }
1093
1094 $userId = get_current_user_id();
1095
1096 if ((int) $booking->host_user_id === (int) $userId) {
1097 return true;
1098 }
1099
1100 if (in_array((int) $userId, array_map('intval', (array) $booking->getHostIds()), true)) {
1101 return true;
1102 }
1103
1104 // Match MeetingPolicy::hasBookingAccess(): a host may act on a booking of
1105 // an event they host, not on every booking on a calendar they can write.
1106 if (!PermissionManager::userCan('manage_own_calendar')) {
1107 return false;
1108 }
1109
1110 $event = $booking->calendar_event ?: CalendarSlot::find($booking->event_id);
1111
1112 return $event && in_array((int) $userId, array_map('intval', (array) $event->getHostIds()), true);
1113 }
1114
1115 /**
1116 * Notifications are on unless the caller turns them off — a booking the
1117 * attendee never hears about is a strange default, and matches what the
1118 * same action in wp-admin would do.
1119 *
1120 * @param array $params
1121 *
1122 * @return bool
1123 */
1124 /**
1125 * Whether the caller asked for notifications on this change.
1126 *
1127 * Reported as `notifications_requested`, not `notifications_sent`: nothing
1128 * here waits on SMTP, calendar sync or Twilio, so it cannot claim delivery.
1129 * Failures land in the booking's activity log.
1130 *
1131 * @param array $params
1132 *
1133 * @return bool
1134 */
1135 public static function wantsNotifications($params)
1136 {
1137 if (!array_key_exists('send_notifications', (array) $params)) {
1138 return true;
1139 }
1140
1141 return Arr::isTrue($params, 'send_notifications');
1142 }
1143
1144 /**
1145 * Everything create() checks before it touches the slot engine, so a dry run
1146 * can run the same gauntlet.
1147 *
1148 * A preview that succeeds and an execute that then fails on `location_required`
1149 * is worse than no preview: the agent reports "ready to book" to a human,
1150 * gets approval, and only then discovers the call was never valid. The
1151 * preview is a promise about the execute, so it has to be checked against
1152 * the same rules.
1153 *
1154 * Availability is deliberately NOT part of this — it is re-checked at
1155 * execute time by design, and the preview says so.
1156 *
1157 * @param CalendarSlot $event
1158 * @param array $params
1159 *
1160 * @return true|\WP_Error
1161 */
1162 public static function validateCreate(CalendarSlot $event, $params)
1163 {
1164 if ($event->status !== 'active') {
1165 return MCPHelper::error(
1166 'event_not_bookable',
1167 /* translators: %s: the event type's current status */
1168 sprintf(__('This event type is "%s", not active, so it is not accepting bookings.', 'fluent-booking'), $event->status),
1169 ['event_status' => $event->status]
1170 );
1171 }
1172
1173 $email = sanitize_email(Arr::get($params, 'email', ''));
1174
1175 if (!$email || !is_email($email)) {
1176 return MCPHelper::error('invalid_email', __('A valid attendee email is required.', 'fluent-booking'));
1177 }
1178
1179 if (!sanitize_text_field(Arr::get($params, 'name', ''))) {
1180 return MCPHelper::error('missing_name', __('The attendee name is required.', 'fluent-booking'));
1181 }
1182
1183 $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', ''));
1184
1185 $startTime = MCPHelper::toUtc(Arr::get($params, 'start_time', ''), $timezone);
1186
1187 if (is_wp_error($startTime)) {
1188 return $startTime;
1189 }
1190
1191 $hostUserId = self::resolveHostId($event, $params);
1192
1193 if (is_wp_error($hostUserId)) {
1194 return $hostUserId;
1195 }
1196
1197 $location = self::resolveLocation($event, $params);
1198
1199 if (is_wp_error($location)) {
1200 return $location;
1201 }
1202
1203 $customFieldsData = BookingFieldService::getCustomFieldsData(
1204 (array) Arr::get($params, 'custom_fields', []),
1205 $event
1206 );
1207
1208 if (is_wp_error($customFieldsData)) {
1209 return MCPHelper::error(
1210 'invalid_custom_fields',
1211 $customFieldsData->get_error_message(),
1212 ['errors' => $customFieldsData->get_error_data()]
1213 );
1214 }
1215
1216 return true;
1217 }
1218
1219 /**
1220 * @param CalendarSlot $event
1221 * @param array $params
1222 *
1223 * @return int|null|\WP_Error
1224 */
1225 private static function resolveHostId(CalendarSlot $event, $params)
1226 {
1227 return MCPHelper::resolveEventHost($event, Arr::get($params, 'host_id'));
1228 }
1229
1230 /**
1231 * Log an unexpected failure without letting its text reach the client.
1232 *
1233 * @param string $context
1234 * @param \Throwable $e
1235 */
1236 private static function logException($context, $e)
1237 {
1238 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
1239 error_log('FluentBooking MCP ' . $context . ' failed: ' . get_class($e) . ': ' . $e->getMessage() . ' at ' . basename($e->getFile()) . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1240 }
1241
1242 /**
1243 * Fires when an MCP write fails with an unexpected exception.
1244 *
1245 * @since 2.3.0
1246 *
1247 * @param string $context which write failed
1248 * @param \Throwable $e the failure
1249 */
1250 do_action('fluent_booking/mcp_write_exception', $context, $e);
1251 }
1252
1253 /**
1254 * Resolve the booking's location from the event's configured locations. An
1255 * agent may name a location type; when it does not, and the event offers
1256 * exactly one, we use it — asking a model to choose between one option is
1257 * a round-trip for nothing.
1258 *
1259 * @param CalendarSlot $event
1260 * @param array $params
1261 *
1262 * @return array|\WP_Error
1263 */
1264 private static function resolveLocation(CalendarSlot $event, $params)
1265 {
1266 $locations = [];
1267
1268 foreach ((array) $event->location_settings as $location) {
1269 if (!empty($location['type'])) {
1270 $locations[$location['type']] = $location;
1271 }
1272 }
1273
1274 if (!$locations) {
1275 return MCPHelper::error('no_location', __('This event type has no location configured, so a booking cannot be created for it.', 'fluent-booking'));
1276 }
1277
1278 $requested = sanitize_text_field(Arr::get($params, 'location_type', ''));
1279
1280 if (!$requested) {
1281 if (count($locations) > 1) {
1282 return MCPHelper::error(
1283 'location_required',
1284 __('This event type offers more than one location. Pass location_type to choose one.', 'fluent-booking'),
1285 ['available' => array_keys($locations)]
1286 );
1287 }
1288
1289 $requested = key($locations);
1290 }
1291
1292 if (!isset($locations[$requested])) {
1293 return MCPHelper::error(
1294 'invalid_location',
1295 /* translators: %s: the requested location type */
1296 sprintf(__('"%s" is not one of this event type\'s locations.', 'fluent-booking'), $requested),
1297 ['available' => array_keys($locations)]
1298 );
1299 }
1300
1301 $config = $locations[$requested];
1302 $details = ['type' => $requested];
1303
1304 $supplied = sanitize_textarea_field(Arr::get($params, 'location_description', ''));
1305
1306 // Attendee-supplied locations need a value from the caller; host-supplied
1307 // ones come from the event's own configuration.
1308 if (in_array($requested, ['phone_guest', 'in_person_guest'], true)) {
1309 if (!$supplied) {
1310 return MCPHelper::error(
1311 'location_description_required',
1312 /* translators: %s: the location type */
1313 sprintf(__('The "%s" location needs location_description — the attendee\'s phone number or address.', 'fluent-booking'), $requested)
1314 );
1315 }
1316
1317 $details['description'] = $supplied;
1318 } elseif ($requested === 'phone_organizer') {
1319 $details['description'] = Arr::get($config, 'host_phone_number', '');
1320 } elseif (in_array($requested, ['custom', 'in_person_organizer'], true)) {
1321 $details['description'] = Arr::get($config, 'description', '');
1322 } elseif (in_array($requested, ['google_meet', 'online_meeting', 'zoom_meeting', 'ms_teams'], true)) {
1323 $details['description'] = Arr::get($config, 'meeting_link', '');
1324 $details['online_platform_link'] = $details['description'];
1325 }
1326
1327 return $details;
1328 }
1329
1330 /**
1331 * Additional guests, clamped to whatever the event's guest field allows and,
1332 * for group events, to the seats actually left in the slot.
1333 *
1334 * @param array $params
1335 * @param CalendarSlot $event
1336 * @param array|bool $availableSpot
1337 *
1338 * @return array
1339 */
1340 private static function sanitizeGuests($params, CalendarSlot $event, $availableSpot)
1341 {
1342 $isMultiGuest = $event->isMultiGuestEvent();
1343
1344 $guests = [];
1345 $rejected = [];
1346
1347 foreach ((array) Arr::get($params, 'guests', []) as $guest) {
1348 // Accept either shape. An agent naturally sends addresses; a group
1349 // event needs a name per seat, so an object is allowed too.
1350 if (is_array($guest)) {
1351 $email = sanitize_email((string) Arr::get($guest, 'email', ''));
1352 $name = sanitize_text_field((string) Arr::get($guest, 'name', ''));
1353 } else {
1354 $email = sanitize_email((string) $guest);
1355 $name = '';
1356 }
1357
1358 if (!$email || !is_email($email)) {
1359 $rejected[] = [
1360 'value' => is_array($guest) ? (string) Arr::get($guest, 'email', '') : (string) $guest,
1361 'reason' => 'invalid_email',
1362 ];
1363 continue;
1364 }
1365
1366 if (!$isMultiGuest) {
1367 $guests[] = $email;
1368 continue;
1369 }
1370
1371 // A multi-guest event seats each guest as their own attendee, and
1372 // BookingService::prepareBookingData() reads $guest['name'] and
1373 // $guest['email'] off every entry. Handing it bare strings raised
1374 // "Cannot access offset of type string on string" on PHP 8 and
1375 // produced nameless attendees on 7.4 — so the group-event path, the
1376 // one the seat arithmetic below exists for, could never work.
1377 $guests[] = [
1378 'name' => $name ?: self::nameFromEmail($email),
1379 'email' => $email,
1380 ];
1381 }
1382
1383 $guestField = BookingFieldService::getBookingFieldByName($event, 'guests');
1384 $limit = (int) Arr::get($guestField, 'limit', 10);
1385 $reason = 'over_field_limit';
1386
1387 if ($isMultiGuest && is_array($availableSpot)) {
1388 $remaining = (int) Arr::get($availableSpot, 'remaining', $event->getMaxBookingPerSlot());
1389
1390 // Minus one: the attendee themself takes a seat. On a group event
1391 // with a single seat left this is 0, which drops every guest.
1392 if (min($remaining, $limit) - 1 < $limit) {
1393 $reason = 'no_seats_left';
1394 }
1395
1396 $limit = min($remaining, $limit) - 1;
1397 }
1398
1399 $kept = $limit > 0 ? array_slice(array_values($guests), 0, $limit) : [];
1400
1401 foreach (array_slice(array_values($guests), count($kept)) as $dropped) {
1402 $rejected[] = [
1403 'value' => is_array($dropped) ? Arr::get($dropped, 'email', '') : $dropped,
1404 'reason' => $reason,
1405 ];
1406 }
1407
1408 // Every one of these three drops used to be silent, so an agent asked
1409 // to book four people was told `created: true` for a booking with one.
1410 self::$guestsDropped = $rejected;
1411
1412 return $kept;
1413 }
1414
1415 /**
1416 * How many requested guests a create could actually seat, for the preview.
1417 *
1418 * Addresses and the guest field's own limit only — a group event's free
1419 * seats are re-read at execute time, so this is a ceiling, not a promise.
1420 *
1421 * @param CalendarSlot $event
1422 * @param array $params
1423 *
1424 * @return int
1425 */
1426 public static function previewGuestCount(CalendarSlot $event, $params)
1427 {
1428 $valid = 0;
1429
1430 foreach ((array) Arr::get($params, 'guests', []) as $guest) {
1431 $email = sanitize_email(is_array($guest) ? (string) Arr::get($guest, 'email', '') : (string) $guest);
1432
1433 if ($email && is_email($email)) {
1434 $valid++;
1435 }
1436 }
1437
1438 $guestField = BookingFieldService::getBookingFieldByName($event, 'guests');
1439
1440 return min($valid, (int) Arr::get($guestField, 'limit', 10));
1441 }
1442
1443 /**
1444 * Guests the last create() was asked for and did not book.
1445 *
1446 * Request-scoped: set by sanitizeGuests() during the create, read once by
1447 * the tool building the response. One MCP call creates one booking.
1448 *
1449 * @return array
1450 */
1451 public static function droppedGuests()
1452 {
1453 return self::$guestsDropped;
1454 }
1455
1456 /**
1457 * A display name for a guest who was given as a bare address.
1458 *
1459 * @param string $email
1460 * @return string
1461 */
1462 private static function nameFromEmail($email)
1463 {
1464 $local = strstr((string) $email, '@', true);
1465
1466 $name = trim(str_replace(['.', '_', '-', '+'], ' ', (string) $local));
1467
1468 return $name ? ucwords($name) : (string) $email;
1469 }
1470
1471 /**
1472 * Every MCP write leaves a trail naming the operator and the channel, so a
1473 * booking's timeline distinguishes an agent's action from a human's.
1474 *
1475 * @param Booking $booking
1476 * @param string $title
1477 * @param string $description
1478 * @param bool $notified
1479 */
1480 private static function logActivity(Booking $booking, $title, $description, $notified)
1481 {
1482 if (!$notified) {
1483 $description .= ' ' . __('Notifications were suppressed for this action.', 'fluent-booking');
1484 }
1485
1486 do_action('fluent_booking/log_booking_activity', [
1487 'booking_id' => $booking->id,
1488 'type' => 'info',
1489 'status' => 'closed',
1490 'title' => $title,
1491 'description' => $description,
1492 ]);
1493 }
1494
1495 /**
1496 * @return string
1497 */
1498 private static function actorName()
1499 {
1500 $user = wp_get_current_user();
1501
1502 if ($user && $user->exists()) {
1503 return $user->display_name ? $user->display_name : $user->user_login;
1504 }
1505
1506 return __('An MCP client', 'fluent-booking');
1507 }
1508 }
1509