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
← All changes | app/Modules/MCP/Support/BookingWriter.php +91 -214 2.4.0 → 2.5.0 View file →
@@ -16,32 +16,23 @@
16 16
17 17 defined('ABSPATH') || exit;
18 18
19 19 /**
20 - * Every mutation the MCP server performs on a booking, in one place.
20 + * Every booking mutation the MCP server performs.
21 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.
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.
30 27 *
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 28 * @since 2.2.6
37 29 */
38 30 class BookingWriter
39 31 {
40 32 /**
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).
33 + * Columns `update_details` may write: patchBooking()'s whitelist minus
34 + * status and payment_status, which have their own actions.
44 35 */
45 36 const EDITABLE_FIELDS = ['first_name', 'last_name', 'email', 'phone', 'internal_note'];
46 37
47 38 /**
@@ -49,11 +40,9 @@
49 40 */
50 41 private static $guestsDropped = [];
51 42
52 43 /**
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.
44 + * Allowed status transitions, mirroring the model and admin rules.
56 45 */
57 46 public static function statusTransitions()
58 47 {
59 48 return [
@@ -65,16 +54,11 @@
65 54 ];
66 55 }
67 56
68 57 /**
69 - * Create a booking on an attendee's behalf.
58 + * Create a booking on an attendee's behalf, following the same steps as
59 + * BookingController::createBooking().
70 60 *
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 61 * @param CalendarSlot $event
78 62 * @param array $params
79 63 *
80 64 * @return Booking|\WP_Error
@@ -127,11 +111,10 @@
127 111 'email' => $email,
128 112 'message' => sanitize_textarea_field(Arr::get($params, 'message', '')),
129 113 'phone' => sanitize_text_field(Arr::get($params, 'phone', '')),
130 114 '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.
115 + // Not 'admin': that source drives UI that assumes a human filled
116 + // the form, and reports should tell the two apart.
134 117 'source' => 'mcp',
135 118 'event_type' => $event->event_type,
136 119 'slot_minutes' => $duration,
137 120 ];
@@ -164,10 +147,9 @@
164 147 ['errors' => $customFieldsData->get_error_data()]
165 148 );
166 149 }
167 150
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.
151 + // Pick the same round-robin host the public page would.
170 152 if ($event->isRoundRobin() && !$hostUserId) {
171 153 $sortedHostIds = $event->getHostIdsSortedByBookings($startTime);
172 154 $bookingData['host_user_id'] = $sortedHostIds[0];
173 155 } elseif ($hostUserId) {
@@ -179,20 +161,11 @@
179 161 if (is_wp_error($service)) {
180 162 return MCPHelper::error('slot_service_unavailable', $service->get_error_message());
181 163 }
182 164
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.
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.
195 168 $lock = self::lockSlot($event, $startTime, $endTime, $hostUserId);
196 169 $hostLock = false;
197 170
198 171 if (!$lock) {
@@ -216,12 +189,10 @@
216 189 ]
217 190 );
218 191 }
219 192
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.
193 + // isSpotAvailable() can outrun the lease (team event, cold calendar
194 + // cache) and an expired lease can be taken, so re-assert it.
224 195 if (!SlotLock::renewAll($lock)) {
225 196 return MCPHelper::error(
226 197 'slot_locked',
227 198 __('Another booking for this slot was created while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
@@ -233,10 +204,9 @@
233 204 $hostUserId = (int) $service->hostUserId;
234 205
235 206 $bookingData['host_user_id'] = $hostUserId;
236 207
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.
208 + // Round robin has a host only now, so claim it.
239 209 $hostLock = SlotLock::acquireInterval($event->id, $startTime, $endTime, [$hostUserId]);
240 210
241 211 if (!$hostLock) {
242 212 return MCPHelper::error(
@@ -245,11 +215,10 @@
245 215 ['requested_start' => $startTime]
246 216 );
247 217 }
248 218
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.
219 + // Another event type could have booked this host before the
220 + // host lock, so re-check under it.
252 221 $availableSpot = $service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId);
253 222
254 223 if (!$availableSpot) {
255 224 return MCPHelper::error(
@@ -284,13 +253,10 @@
284 253 };
285 254
286 255 $booking = $notify ? $create() : NotificationGate::silently($create);
287 256 } 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.
257 + // Never return $e->getMessage(): DB errors leak table names, SQL
258 + // and paths, bypassing AbilitiesRegistrar's scrubbing.
293 259 self::logException('create-booking', $e);
294 260
295 261 return MCPHelper::error(
296 262 'booking_failed',
@@ -296,11 +262,10 @@
296 262 'booking_failed',
297 263 __('The booking could not be created. The site logged the details.', 'fluent-booking')
298 264 );
299 265 } 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.
266 + // Release on every exit, early returns included, so a failure
267 + // doesn't block the slot for the lock's TTL.
303 268 SlotLock::releaseAll($lock);
304 269 SlotLock::releaseAll($hostLock);
305 270 }
306 271
@@ -323,11 +288,10 @@
323 288 return $booking;
324 289 }
325 290
326 291 /**
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.
292 + * Move a booking to a new time via the same RescheduleService the public
293 + * booking form uses.
330 294 *
331 295 * @param Booking $booking
332 296 * @param array $params
333 297 *
@@ -349,15 +313,10 @@
349 313 ['status' => $booking->status]
350 314 );
351 315 }
352 316
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.
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.
360 319 $requested = trim((string) Arr::get($params, 'timezone', ''));
361 320
362 321 $timezone = $requested
363 322 ? MCPHelper::resolveTimezone($requested)
@@ -383,10 +342,9 @@
383 342 if (is_wp_error($service)) {
384 343 return MCPHelper::error('slot_service_unavailable', $service->get_error_message());
385 344 }
386 345
387 - // Same check-then-write race as create(), and the same hold over it,
388 - // round-robin key included.
346 + // Same check-then-write lock as create().
389 347 $lock = self::lockSlot($event, $startTime, $endTime, $hostUserId);
390 348 $hostLock = false;
391 349
392 350 if (!$lock) {
@@ -397,10 +355,9 @@
397 355 );
398 356 }
399 357
400 358 try {
401 - // Re-check at execute time, not just at preview time — the slot may
402 - // have been taken during the confirm round-trip.
359 + // The slot may have been taken during the confirm round-trip.
403 360 if (!$service->isSpotAvailable($startTime, $endTime, $duration, $hostUserId)) {
404 361 return MCPHelper::error(
405 362 'slot_unavailable',
406 363 __('That time is no longer available. Call get-available-slots for the current openings.', 'fluent-booking'),
@@ -410,10 +367,9 @@
410 367 ]
411 368 );
412 369 }
413 370
414 - // Same reason as create(): isSpotAvailable() can outrun the lease,
415 - // and an expired one is stolen without question.
371 + // As in create(): isSpotAvailable() can outrun the lease.
416 372 if (!SlotLock::renewAll($lock)) {
417 373 return MCPHelper::error(
418 374 'slot_locked',
419 375 __('Another booking for the target slot was written while this one was being checked. Call get-available-slots before retrying.', 'fluent-booking'),
@@ -461,11 +417,10 @@
461 417 return RescheduleService::reschedule($booking, $event, $startTime, $timezone, [
462 418 'reason' => Arr::get($params, 'reason', ''),
463 419 'host_user_id' => $hostUserId,
464 420 '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.
421 + // The agent holds host credentials, so the guest-side
422 + // reschedule window doesn't apply.
468 423 'rescheduled_by' => 'host',
469 424 ]);
470 425 };
471 426
@@ -485,11 +440,9 @@
485 440 if (is_wp_error($result)) {
486 441 return MCPHelper::error('reschedule_failed', $result->get_error_message());
487 442 }
488 443
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.
444 + // RescheduleService's own row records the role ("by host"), not the person.
492 445 self::logActivity(
493 446 $result,
494 447 __('Booking Rescheduled via MCP', 'fluent-booking'),
495 448 /* translators: %1$s: acting user, %2$s: the new start time in UTC */
@@ -529,10 +482,9 @@
529 482 ['status' => $booking->status]
530 483 );
531 484 }
532 485
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.
486 + // Both statuses mean the meeting already happened.
535 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
536 488 return MCPHelper::error(
537 489 'not_yet_occurred',
538 490 /* translators: %s: the requested status */
@@ -580,11 +532,10 @@
580 532 return Booking::with(['calendar_event'])->find($booking->id);
581 533 }
582 534
583 535 /**
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.
536 + * The transition itself. Cancel and reject go through the model methods;
537 + * the others mirror patchBooking()'s sequence.
587 538 *
588 539 * @return true|\WP_Error
589 540 */
590 541 private static function transition(Booking $booking, $action, $target, $reason, $params)
@@ -591,13 +542,9 @@
591 542 {
592 543 $from = self::statusTransitions()[$action]['from'];
593 544
594 545 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.
546 + // Re-read so the model's status guard sees current data.
600 547 $fresh = Booking::find($booking->id);
601 548
602 549 if (!$fresh || !in_array($fresh->status, $from, true)) {
603 550 return MCPHelper::error(
@@ -608,15 +555,11 @@
608 555 }
609 556
610 557 $priorStatus = $fresh->status;
611 558
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.
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.
619 562 $claimed = Booking::where('id', $fresh->id)
620 563 ->whereIn('status', $from)
621 564 ->update(['status' => $target]);
622 565
@@ -627,21 +570,15 @@
627 570 ['status' => Booking::where('id', $fresh->id)->value('status')]
628 571 );
629 572 }
630 573
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.
574 + // Restore the real prior status in memory so cancelMeeting() doesn't
575 + // short-circuit on "already cancelled" and sees pending vs scheduled.
635 576 $fresh->status = $priorStatus;
636 577
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.
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.
644 581 $notified = false;
645 582
646 583 $marker = function () use (&$notified) {
647 584 $notified = true;
@@ -681,10 +618,9 @@
681 618 } finally {
682 619 remove_action($hook, $marker, PHP_INT_MIN);
683 620 }
684 621
685 - // The refund runs after the cancellation is already out. Failing
686 - // to move the money is not a reason to un-cancel.
622 + // A failed refund doesn't un-cancel the booking.
687 623 try {
688 624 self::maybeRefund($fresh, $params);
689 625 } catch (\Throwable $e) {
690 626 self::logException('manage-booking:' . $action . ':refund', $e);
@@ -698,16 +634,13 @@
698 634
699 635 return true;
700 636 }
701 637
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.
638 + // The claim doesn't report which allowed status the row held, and a
639 + // rollback needs it.
705 640 $priorStatus = Booking::where('id', $booking->id)->value('status');
706 641
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.
642 + // Compare-and-set so only one racing request fires the side effects.
710 643 $claimed = Booking::where('id', $booking->id)
711 644 ->whereIn('status', $from)
712 645 ->update(['status' => $target]);
713 646
@@ -722,15 +655,11 @@
722 655 $booking->status = $target;
723 656
724 657 $notified = false;
725 658
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.
659 + // Same rollback rule as the cancel branch.
730 660 try {
731 - // Confirming a booking that was paid for settles its order, exactly
732 - // as the admin's confirm does.
661 + // Confirming a paid booking settles its order, as the admin does.
733 662 if ($action === 'confirm' && $booking->payment_method && $booking->payment_order) {
734 663 $settled = self::settlePayment($booking, $booking->payment_order);
735 664
736 665 if (is_wp_error($settled)) {
@@ -738,11 +667,9 @@
738 667
739 668 return $settled;
740 669 }
741 670
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.
671 + // Payment reporting reads this activity row.
745 672 do_action('fluent_booking/log_booking_activity', [
746 673 'booking_id' => $booking->id,
747 674 'status' => 'closed',
748 675 'type' => 'success',
@@ -753,9 +680,9 @@
753 680
754 681 do_action('fluent_booking/payment/update_payment_status_paid', $booking);
755 682 }
756 683
757 - // Same commit point as the cancel branch.
684 + // Past this point the attendee may be notified; no rollback.
758 685 $notified = true;
759 686
760 687 do_action('fluent_booking/booking_schedule_' . $target, $booking, $booking->calendar_event);
761 688
@@ -770,13 +697,10 @@
770 697 if ($notified) {
771 698 return self::partiallyCompleted($booking->id, $target);
772 699 }
773 700
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.
701 + // Only the status is rolled back. Paid but pending is a normal
702 + // state for events that need confirmation.
779 703 self::rollbackStatus($booking->id, $priorStatus, $target);
780 704
781 705 return MCPHelper::error(
782 706 'transition_failed',
@@ -787,18 +711,11 @@
787 711 return true;
788 712 }
789 713
790 714 /**
791 - * Mark an order paid and the booking with it, as one commit.
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.
792 717 *
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 718 * @param Booking $booking
802 719 * @param object $order
803 720 *
804 721 * @return true|\WP_Error
@@ -827,9 +744,11 @@
827 744 return true;
828 745 }
829 746
830 747 /**
831 - * Claim the slot for every host this booking would occupy.
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.
832 751 *
833 752 * @param CalendarSlot $event
834 753 * @param string $startTime
835 754 * @param string $endTime
@@ -857,13 +776,10 @@
857 776 }
858 777
859 778 /**
860 779 * Undo a claimed status transition whose side effects did not complete.
780 + * Only applies if the row still holds the claimed status.
861 781 *
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 782 * @param int $bookingId
867 783 * @param string $priorStatus
868 784 * @param string $claimedStatus
869 785 */
@@ -893,11 +809,10 @@
893 809 );
894 810 }
895 811
896 812 /**
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.
813 + * Refund a cancelled or rejected paid booking, only when refund_payment is
814 + * explicitly set.
900 815 */
901 816 private static function maybeRefund(Booking $booking, $params)
902 817 {
903 818 if (!$booking->payment_method || !Arr::isTrue($params, 'refund_payment')) {
@@ -907,10 +822,9 @@
907 822 do_action('fluent_booking/refund_payment_' . $booking->payment_method, $booking, $booking->calendar_event);
908 823 }
909 824
910 825 /**
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.
826 + * Edit an attendee's details. Reversible, so no confirm token.
913 827 *
914 828 * @param Booking $booking
915 829 * @param array $fields
916 830 * @param array $params
@@ -975,11 +889,9 @@
975 889 $booking->fill($updates);
976 890 $booking->save();
977 891
978 892 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.
893 + // One hook per column, as patchBooking fires them.
982 894 do_action('fluent_booking/after_patch_booking_' . $key, $booking, $booking->calendar_event, $before[$key]);
983 895 }
984 896
985 897 return true;
@@ -1008,11 +920,9 @@
1008 920 * @return array|\WP_Error
1009 921 */
1010 922 public static function resendEmail(Booking $booking, $emailTo, $params = [])
1011 923 {
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.
924 + // The action is sending an email, so refuse if notifications are off.
1015 925 if (!self::wantsNotifications($params)) {
1016 926 return MCPHelper::error(
1017 927 'notifications_disabled',
1018 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')
@@ -1018,10 +928,9 @@
1018 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')
1019 929 );
1020 930 }
1021 931
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.
932 + // The template says the booking is going ahead.
1024 933 if (!in_array($booking->status, ['scheduled', 'rescheduled', 'pending'], true)) {
1025 934 return MCPHelper::error(
1026 935 'not_resendable',
1027 936 /* translators: %s: the booking's current status */
@@ -1076,11 +985,10 @@
1076 985 return ['recipient' => $emailTo, 'sent' => true];
1077 986 }
1078 987
1079 988 /**
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.
989 + * Whether the caller may change this booking: site-wide booking access,
990 + * or being a host of the booking or its event.
1083 991 *
1084 992 * @param Booking $booking
1085 993 *
1086 994 * @return bool
@@ -1112,23 +1020,14 @@
1112 1020 return $event && in_array((int) $userId, array_map('intval', (array) $event->getHostIds()), true);
1113 1021 }
1114 1022
1115 1023 /**
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.
1024 + * Whether the caller asked for notifications on this change. On unless
1025 + * turned off, matching wp-admin.
1119 1026 *
1120 - * @param array $params
1027 + * Reported as `notifications_requested`, not `sent`: nothing here waits on
1028 + * delivery.
1121 1029 *
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 1030 * @param array $params
1132 1031 *
1133 1032 * @return bool
1134 1033 */
@@ -1141,20 +1040,12 @@
1141 1040 return Arr::isTrue($params, 'send_notifications');
1142 1041 }
1143 1042
1144 1043 /**
1145 - * Everything create() checks before it touches the slot engine, so a dry run
1146 - * can run the same gauntlet.
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.
1147 1047 *
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 1048 * @param CalendarSlot $event
1158 1049 * @param array $params
1159 1050 *
1160 1051 * @return true|\WP_Error
@@ -1250,12 +1141,10 @@
1250 1141 do_action('fluent_booking/mcp_write_exception', $context, $e);
1251 1142 }
1252 1143
1253 1144 /**
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.
1145 + * Resolve the booking's location from the event's configured locations.
1146 + * With no location_type given, a single configured location is used.
1258 1147 *
1259 1148 * @param CalendarSlot $event
1260 1149 * @param array $params
1261 1150 *
@@ -1344,10 +1233,9 @@
1344 1233 $guests = [];
1345 1234 $rejected = [];
1346 1235
1347 1236 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.
1237 + // An email string or a {name, email} object.
1350 1238 if (is_array($guest)) {
1351 1239 $email = sanitize_email((string) Arr::get($guest, 'email', ''));
1352 1240 $name = sanitize_text_field((string) Arr::get($guest, 'name', ''));
1353 1241 } else {
@@ -1367,14 +1255,10 @@
1367 1255 $guests[] = $email;
1368 1256 continue;
1369 1257 }
1370 1258
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.
1259 + // Multi-guest events seat each guest as an attendee, and
1260 + // BookingService::prepareBookingData() expects name and email keys.
1377 1261 $guests[] = [
1378 1262 'name' => $name ?: self::nameFromEmail($email),
1379 1263 'email' => $email,
1380 1264 ];
@@ -1386,10 +1270,9 @@
1386 1270
1387 1271 if ($isMultiGuest && is_array($availableSpot)) {
1388 1272 $remaining = (int) Arr::get($availableSpot, 'remaining', $event->getMaxBookingPerSlot());
1389 1273
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.
1274 + // Minus one for the attendee's own seat.
1392 1275 if (min($remaining, $limit) - 1 < $limit) {
1393 1276 $reason = 'no_seats_left';
1394 1277 }
1395 1278
@@ -1404,10 +1287,9 @@
1404 1287 'reason' => $reason,
1405 1288 ];
1406 1289 }
1407 1290
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.
1291 + // Reported back so the agent knows which guests weren't booked.
1410 1292 self::$guestsDropped = $rejected;
1411 1293
1412 1294 return $kept;
1413 1295 }
@@ -1412,13 +1294,11 @@
1412 1294 return $kept;
1413 1295 }
1414 1296
1415 1297 /**
1416 - * How many requested guests a create could actually seat, for the preview.
1298 + * How many requested guests a create could seat, for the preview. A ceiling:
1299 + * group event seats are only checked at execute time.
1417 1300 *
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 1301 * @param CalendarSlot $event
1422 1302 * @param array $params
1423 1303 *
1424 1304 * @return int
@@ -1440,13 +1320,11 @@
1440 1320 return min($valid, (int) Arr::get($guestField, 'limit', 10));
1441 1321 }
1442 1322
1443 1323 /**
1444 - * Guests the last create() was asked for and did not book.
1324 + * Guests the last create() was asked for and did not book. Request-scoped;
1325 + * one MCP call creates one booking.
1445 1326 *
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 1327 * @return array
1450 1328 */
1451 1329 public static function droppedGuests()
1452 1330 {
@@ -1468,10 +1346,9 @@
1468 1346 return $name ? ucwords($name) : (string) $email;
1469 1347 }
1470 1348
1471 1349 /**
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.
1350 + * Log an activity row naming the operator and the MCP channel.
1474 1351 *
1475 1352 * @param Booking $booking
1476 1353 * @param string $title
1477 1354 * @param string $description