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

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

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