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