| @@ -15,41 +15,28 @@ | ||
| 15 | 15 | |
| 16 | 16 | defined('ABSPATH') || exit; |
| 17 | 17 | |
| 18 | 18 | /** |
| 19 | - * The two tools that change something. | |
| 19 | + * The write tools. `create-booking` has its own parameter shape; everything | |
| 20 | + * acting on an existing booking goes through `manage-booking` behind an | |
| 21 | + * `action` enum. | |
| 20 | 22 | * |
| 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. | |
| 23 | + * Destructive calls need a confirm_token from a dry run (see WriteGuard). | |
| 24 | + * Whether a call is destructive is decided per call, see needsConfirmation(). | |
| 25 | 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 | 26 | * @see \FluentBooking\App\Modules\MCP\Support\WriteGuard for the contract. |
| 38 | 27 | */ |
| 39 | 28 | class BookingWriteTools |
| 40 | 29 | { |
| 41 | 30 | /** |
| 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). | |
| 31 | + * manage-booking actions that always need a preview: they can't be undone | |
| 32 | + * (cancel, reject) or move a real person's calendar entry (reschedule). | |
| 45 | 33 | */ |
| 46 | 34 | const DESTRUCTIVE_ACTIONS = ['reschedule', 'cancel', 'reject']; |
| 47 | 35 | |
| 48 | 36 | /** |
| 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. | |
| 37 | + * Ceiling on `guests`, enforced in the schema so an oversized payload is | |
| 38 | + * refused before the sanitizer walks it. The seat limit applies later. | |
| 52 | 39 | */ |
| 53 | 40 | const MAX_GUESTS = 50; |
| 54 | 41 | |
| 55 | 42 | public static function definitions() |
| @@ -252,12 +239,10 @@ | ||
| 252 | 239 | } |
| 253 | 240 | |
| 254 | 241 | $tool = 'fluent-booking/create-booking'; |
| 255 | 242 | $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. | |
| 243 | + // No existing entity, so bind the token to the slot being claimed. The | |
| 244 | + // availability re-check at execute time prevents double-booking. | |
| 260 | 245 | $entityKey = 'event:' . $eventId . ':' . Arr::get($params, 'start_time', '') . ':' . strtolower((string) Arr::get($params, 'email', '')); |
| 261 | 246 | |
| 262 | 247 | $digest = WriteGuard::paramsDigest($params); |
| 263 | 248 | |
| @@ -274,13 +259,10 @@ | ||
| 274 | 259 | WriteGuard::CONFIRM_NEXT_STEP |
| 275 | 260 | ); |
| 276 | 261 | } |
| 277 | 262 | |
| 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. | |
| 263 | + // idempotent() must wrap confirm(): confirm() consumes the token, so a | |
| 264 | + // retry would otherwise fail before reaching the recorded result. | |
| 283 | 265 | return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $event, $params, $timezone, $digest) { |
| 284 | 266 | $confirmed = WriteGuard::confirm($tool, $entityKey, self::createFingerprint($event), Arr::get($params, 'confirm_token', ''), $digest); |
| 285 | 267 | |
| 286 | 268 | if (is_wp_error($confirmed)) { |
| @@ -297,11 +279,9 @@ | ||
| 297 | 279 | 'created' => true, |
| 298 | 280 | 'booking' => BookingProjector::full($booking, $timezone), |
| 299 | 281 | ]; |
| 300 | 282 | |
| 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. | |
| 283 | + // Name any requested guest who wasn't seated. | |
| 304 | 284 | if ($dropped = BookingWriter::droppedGuests()) { |
| 305 | 285 | $data['guests_dropped'] = $dropped; |
| 306 | 286 | } |
| 307 | 287 | |
| @@ -364,11 +344,9 @@ | ||
| 364 | 344 | return $preview; |
| 365 | 345 | } |
| 366 | 346 | |
| 367 | 347 | 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. | |
| 348 | + // Reversible actions still honour dry_run; they just need no token. | |
| 371 | 349 | return MCPHelper::success( |
| 372 | 350 | ['dry_run' => true, 'preview' => $preview], |
| 373 | 351 | ['timezone' => $timezone], |
| 374 | 352 | 'Nothing was changed. This action is reversible — call again without dry_run to apply it; no confirm_token is needed.' |
| @@ -381,10 +359,9 @@ | ||
| 381 | 359 | WriteGuard::CONFIRM_NEXT_STEP |
| 382 | 360 | ); |
| 383 | 361 | } |
| 384 | 362 | |
| 385 | - // See createBooking(): the idempotency wrapper has to sit OUTSIDE the | |
| 386 | - // confirm-token check, because the check is one-shot. | |
| 363 | + // idempotent() wraps confirm(), see createBooking(). | |
| 387 | 364 | return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $booking, $action, $params, $timezone, $digest, $needsConfirmation) { |
| 388 | 365 | if ($needsConfirmation) { |
| 389 | 366 | $confirmed = WriteGuard::confirm( |
| 390 | 367 | $tool, |
| @@ -405,15 +382,11 @@ | ||
| 405 | 382 | }); |
| 406 | 383 | } |
| 407 | 384 | |
| 408 | 385 | /** |
| 409 | - * Rebuild a write's response from the reference the idempotency record | |
| 410 | - * keeps, reading the booking as it stands now. | |
| 386 | + * Rebuild a write's response from the idempotency record's booking id, | |
| 387 | + * reading the booking as it stands now. See WriteGuard::idempotent(). | |
| 411 | 388 | * |
| 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 | 389 | * @param array $ref |
| 417 | 390 | * @param string $timezone |
| 418 | 391 | * @param array $extra |
| 419 | 392 | * |
| @@ -439,24 +412,16 @@ | ||
| 439 | 412 | ); |
| 440 | 413 | } |
| 441 | 414 | |
| 442 | 415 | /** |
| 443 | - * Whether this particular call has to be previewed and confirmed first. | |
| 416 | + * Whether this call has to be previewed and confirmed first. Besides | |
| 417 | + * DESTRUCTIVE_ACTIONS, two cases depend on the parameters: | |
| 444 | 418 | * |
| 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": | |
| 419 | + * - `update_details` changing `email`: future emails, join link included, | |
| 420 | + * go to the new address without the attendee being told. | |
| 421 | + * - `confirm` with an unsettled payment order: it marks the order paid and | |
| 422 | + * fires the payment-completed hooks. | |
| 448 | 423 | * |
| 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 | 424 | * @param Booking $booking |
| 460 | 425 | * @param string $action |
| 461 | 426 | * @param array $params |
| 462 | 427 | * @return bool |
| @@ -521,17 +486,16 @@ | ||
| 521 | 486 | ); |
| 522 | 487 | } |
| 523 | 488 | |
| 524 | 489 | /** |
| 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. | |
| 490 | + * What a create would do, without doing it. Includes who would be emailed, | |
| 491 | + * so the operator sees it before approving. | |
| 528 | 492 | * |
| 529 | 493 | * @return array|\WP_Error |
| 530 | 494 | */ |
| 531 | 495 | private static function previewCreate(CalendarSlot $event, $params, $timezone) |
| 532 | 496 | { |
| 533 | - // The same checks the execute runs, so "the dry run worked" means something. | |
| 497 | + // Same checks as execute, so a passing dry run means something. | |
| 534 | 498 | $valid = BookingWriter::validateCreate($event, $params); |
| 535 | 499 | |
| 536 | 500 | if (is_wp_error($valid)) { |
| 537 | 501 | return $valid; |
| @@ -573,14 +537,11 @@ | ||
| 573 | 537 | return $preview; |
| 574 | 538 | } |
| 575 | 539 | |
| 576 | 540 | /** |
| 577 | - * Whether the requested slot is free, for the preview only. | |
| 541 | + * Whether the requested slot is free, for the preview only. Advisory: true | |
| 542 | + * means "free a moment ago", not a reservation. null when unknown. | |
| 578 | 543 | * |
| 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 | 544 | * @return bool|null |
| 584 | 545 | */ |
| 585 | 546 | private static function previewSlotAvailability(CalendarSlot $event, $params, $timezone) |
| 586 | 547 | { |
| @@ -615,11 +576,10 @@ | ||
| 615 | 576 | * @return array|\WP_Error |
| 616 | 577 | */ |
| 617 | 578 | private static function previewAction(Booking $booking, $action, $params, $timezone) |
| 618 | 579 | { |
| 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. | |
| 580 | + // Same state-machine check as execute, so we never mint a token for a | |
| 581 | + // transition execute would refuse. | |
| 622 | 582 | $transitions = BookingWriter::statusTransitions(); |
| 623 | 583 | |
| 624 | 584 | if (isset($transitions[$action])) { |
| 625 | 585 | $target = $transitions[$action]['to']; |
| @@ -727,12 +687,10 @@ | ||
| 727 | 687 | return $preview; |
| 728 | 688 | } |
| 729 | 689 | |
| 730 | 690 | /** |
| 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. | |
| 691 | + * Who an action would email, as roles rather than addresses, so a preview | |
| 692 | + * the agent may echo back doesn't leak contact details. | |
| 735 | 693 | * |
| 736 | 694 | * @return array |
| 737 | 695 | */ |
| 738 | 696 | private static function recipientSummary(CalendarSlot $event, $booking = null) |
| @@ -754,12 +712,10 @@ | ||
| 754 | 712 | return $recipients; |
| 755 | 713 | } |
| 756 | 714 | |
| 757 | 715 | /** |
| 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. | |
| 716 | + * Fingerprint the event type, so deactivating or re-timing it between | |
| 717 | + * preview and execute invalidates the token. | |
| 762 | 718 | * |
| 763 | 719 | * @return string |
| 764 | 720 | */ |
| 765 | 721 | private static function createFingerprint(CalendarSlot $event) |