[ 'label' => __('Create booking', 'fluent-booking'), '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'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __('The event type to book. Required.', 'fluent-booking'), ], 'start_time' => [ 'type' => 'string', 'description' => __('Local wall-clock start, Y-m-d H:i:s, read in the timezone parameter. No offset or Z suffix. Required.', 'fluent-booking'), ], 'timezone' => [ 'type' => 'string', 'description' => __('IANA timezone the attendee is booking in. Defaults to the site timezone.', 'fluent-booking'), ], 'name' => [ 'type' => 'string', 'description' => __('Attendee full name. Required.', 'fluent-booking'), 'maxLength' => 200, ], 'email' => [ 'type' => 'string', 'description' => __('Attendee email. Required.', 'fluent-booking'), 'maxLength' => 254, ], 'phone' => ['type' => 'string', 'maxLength' => 40], 'message' => [ 'type' => 'string', 'description' => __('The attendee\'s note, shown to the host.', 'fluent-booking'), 'maxLength' => 5000, ], 'internal_note' => [ 'type' => 'string', 'description' => __('Host-only note. Never shown to the attendee.', 'fluent-booking'), 'maxLength' => 5000, ], 'duration' => [ 'type' => 'integer', 'description' => __('Minutes, when the event type offers a choice. Defaults to its default duration.', 'fluent-booking'), ], 'host_id' => [ 'type' => 'integer', 'description' => __('Pin a specific host. Round-robin events pick one automatically when this is omitted.', 'fluent-booking'), ], 'location_type' => [ 'type' => 'string', 'description' => __('Only when the event type offers several. get-event-types lists them.', 'fluent-booking'), ], 'location_description' => [ 'type' => 'string', 'description' => __('Required for phone_guest and in_person_guest: the attendee\'s number or address.', 'fluent-booking'), ], 'custom_fields' => [ 'type' => 'object', 'description' => __('Answers to the event type\'s booking fields, keyed by field name.', 'fluent-booking'), ], 'guests' => [ 'type' => 'array', 'description' => __('Additional guests. Email strings, or {name, email} objects — group events seat each guest separately and need a name.', 'fluent-booking'), 'items' => ['type' => ['string', 'object']], 'maxItems' => self::MAX_GUESTS, ], 'send_notifications' => [ 'type' => 'boolean', 'description' => __('Default true. Set false to create the booking without notifying anyone — email or SMS; reminders are still scheduled.', 'fluent-booking'), ], 'dry_run' => [ 'type' => 'boolean', 'description' => __('Preview the booking and get a confirm_token without creating anything.', 'fluent-booking'), ], 'confirm_token' => [ 'type' => 'string', 'description' => __('The token from the dry run. Required to actually create the booking.', 'fluent-booking'), ], 'idempotency_key' => [ 'type' => 'string', 'description' => __('A unique key, so a retry after a timeout returns the first result rather than booking twice.', 'fluent-booking'), ], ], 'required' => ['event_id', 'start_time', 'name', 'email'], ], 'annotations' => [ 'title' => __('Create booking', 'fluent-booking'), 'readonly' => false, 'destructive' => true, 'idempotent' => false, ], 'permission_callback' => [PermissionGate::class, 'bookingWriteGate'], 'execute_callback' => [self::class, 'createBooking'], ], 'fluent-booking/manage-booking' => [ 'label' => __('Manage booking', 'fluent-booking'), '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'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'booking_id' => [ 'type' => 'integer', 'description' => __('The booking to act on. Required.', 'fluent-booking'), ], 'action' => [ 'type' => 'string', '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'), 'enum' => ['reschedule', 'cancel', 'confirm', 'reject', 'complete', 'no_show', 'update_details', 'resend_email'], ], 'start_time' => [ 'type' => 'string', 'description' => __('reschedule only. Local wall-clock start, Y-m-d H:i:s, read in the timezone parameter.', 'fluent-booking'), ], 'timezone' => [ 'type' => 'string', 'description' => __('IANA timezone for start_time and for the *_local times in the response.', 'fluent-booking'), ], 'host_id' => [ 'type' => 'integer', 'description' => __('reschedule only. Pin a host on a round-robin event.', 'fluent-booking'), ], 'reason' => [ 'type' => 'string', 'description' => __('Why. Stored on the booking and included in the cancellation, rejection or reschedule email.', 'fluent-booking'), 'maxLength' => 2000, ], 'fields' => [ 'type' => 'object', 'description' => __('update_details only. Any of: first_name, last_name, email, phone, internal_note.', 'fluent-booking'), ], 'recipient' => [ 'type' => 'string', 'description' => __('resend_email only. Who to send the confirmation to. Defaults to guest.', 'fluent-booking'), 'enum' => ['guest', 'host'], ], 'refund_payment' => [ 'type' => 'boolean', 'description' => __('cancel and reject only. Refund through the original gateway. Default false — money never moves as a side effect.', 'fluent-booking'), ], 'send_notifications' => [ 'type' => 'boolean', 'description' => __('Default true. Set false to make the change without notifying anyone — email or SMS; reminders are still scheduled.', 'fluent-booking'), ], 'dry_run' => [ 'type' => 'boolean', 'description' => __('Preview the change and get a confirm_token without applying it.', 'fluent-booking'), ], 'confirm_token' => [ 'type' => 'string', 'description' => __('The token from the dry run. Required for reschedule, cancel and reject.', 'fluent-booking'), ], 'idempotency_key' => [ 'type' => 'string', 'description' => __('Pass a unique key so a retry after a timeout returns the first result instead of acting twice.', 'fluent-booking'), ], ], 'required' => ['booking_id', 'action'], ], 'annotations' => [ 'title' => __('Manage booking', 'fluent-booking'), 'readonly' => false, 'destructive' => true, 'idempotent' => false, ], 'permission_callback' => [PermissionGate::class, 'bookingWriteGate'], 'execute_callback' => [self::class, 'manageBooking'], ], ]; } /** * @param array $params * @return array|\WP_Error */ public static function createBooking($params = []) { $eventId = absint(Arr::get($params, 'event_id')); if (!$eventId) { return MCPHelper::error('missing_event_id', __('event_id is required. Call get-event-types to find one.', 'fluent-booking')); } $event = CalendarSlot::with('calendar')->find($eventId); if (!$event) { return MCPHelper::error('event_not_found', __('No event type with that id.', 'fluent-booking')); } if (!PermissionManager::canWriteCalendar($event->calendar_id)) { return MCPHelper::error( 'permission_denied', __('You do not have permission to create bookings on this calendar.', 'fluent-booking'), ['event_id' => $eventId] ); } $tool = 'fluent-booking/create-booking'; $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); // No existing entity, so bind the token to the slot being claimed. The // availability re-check at execute time prevents double-booking. $entityKey = 'event:' . $eventId . ':' . Arr::get($params, 'start_time', '') . ':' . strtolower((string) Arr::get($params, 'email', '')); $digest = WriteGuard::paramsDigest($params); if (Arr::isTrue($params, 'dry_run')) { $preview = self::previewCreate($event, $params, $timezone); if (is_wp_error($preview)) { return $preview; } return MCPHelper::success( WriteGuard::preview($tool, $entityKey, self::createFingerprint($event), $preview, $digest), ['timezone' => $timezone], WriteGuard::CONFIRM_NEXT_STEP ); } // idempotent() must wrap confirm(): confirm() consumes the token, so a // retry would otherwise fail before reaching the recorded result. return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $event, $params, $timezone, $digest) { $confirmed = WriteGuard::confirm($tool, $entityKey, self::createFingerprint($event), Arr::get($params, 'confirm_token', ''), $digest); if (is_wp_error($confirmed)) { return $confirmed; } $booking = BookingWriter::create($event, $params); if (is_wp_error($booking)) { return $booking; } $data = [ 'created' => true, 'booking' => BookingProjector::full($booking, $timezone), ]; // Name any requested guest who wasn't seated. if ($dropped = BookingWriter::droppedGuests()) { $data['guests_dropped'] = $dropped; } return MCPHelper::success( $data, [ 'timezone' => $timezone, 'notifications_requested' => BookingWriter::wantsNotifications($params), ], 'Call get-booking with this booking_id to see the full record, or list-bookings to confirm it appears in the schedule.' ); }, $digest, function ($ref) use ($timezone) { return self::replayBooking($ref, $timezone, ['created' => true]); }); } /** * @param array $params * @return array|\WP_Error */ public static function manageBooking($params = []) { $action = sanitize_text_field(Arr::get($params, 'action', '')); if (!$action) { return MCPHelper::error('missing_action', __('action is required.', 'fluent-booking')); } $bookingId = absint(Arr::get($params, 'booking_id')); if (!$bookingId) { return MCPHelper::error('missing_booking_id', __('booking_id is required. Call list-bookings to find one.', 'fluent-booking')); } $booking = Booking::with(['calendar_event'])->find($bookingId); if (!$booking) { return MCPHelper::error('booking_not_found', __('No booking with that id.', 'fluent-booking')); } if (!BookingWriter::canWriteBooking($booking)) { return MCPHelper::error( 'permission_denied', __('You do not have permission to change this booking.', 'fluent-booking'), ['booking_id' => $bookingId] ); } $timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); $tool = 'fluent-booking/manage-booking'; $entityKey = 'booking:' . $bookingId . ':' . $action; $digest = WriteGuard::paramsDigest($params); $needsConfirmation = self::needsConfirmation($booking, $action, $params); if (Arr::isTrue($params, 'dry_run')) { $preview = self::previewAction($booking, $action, $params, $timezone); if (is_wp_error($preview)) { return $preview; } if (!$needsConfirmation) { // Reversible actions still honour dry_run; they just need no token. return MCPHelper::success( ['dry_run' => true, 'preview' => $preview], ['timezone' => $timezone], 'Nothing was changed. This action is reversible — call again without dry_run to apply it; no confirm_token is needed.' ); } return MCPHelper::success( WriteGuard::preview($tool, $entityKey, WriteGuard::bookingFingerprint($booking), $preview, $digest), ['timezone' => $timezone], WriteGuard::CONFIRM_NEXT_STEP ); } // idempotent() wraps confirm(), see createBooking(). return WriteGuard::idempotent($tool, $entityKey, Arr::get($params, 'idempotency_key', ''), function () use ($tool, $entityKey, $booking, $action, $params, $timezone, $digest, $needsConfirmation) { if ($needsConfirmation) { $confirmed = WriteGuard::confirm( $tool, $entityKey, WriteGuard::bookingFingerprint($booking), Arr::get($params, 'confirm_token', ''), $digest ); if (is_wp_error($confirmed)) { return $confirmed; } } return self::execute($booking, $action, $params, $timezone); }, $digest, function ($ref) use ($timezone, $action) { return self::replayBooking($ref, $timezone, ['action' => $action]); }); } /** * Rebuild a write's response from the idempotency record's booking id, * reading the booking as it stands now. See WriteGuard::idempotent(). * * @param array $ref * @param string $timezone * @param array $extra * * @return array|null */ private static function replayBooking($ref, $timezone, $extra = []) { $bookingId = (int) Arr::get($ref, 'booking_id'); if (!$bookingId) { return null; } $booking = Booking::with(['calendar_event'])->find($bookingId); if (!$booking) { return null; } return MCPHelper::success( $extra + ['booking' => BookingProjector::full($booking, $timezone)], ['timezone' => $timezone] ); } /** * Whether this call has to be previewed and confirmed first. Besides * DESTRUCTIVE_ACTIONS, two cases depend on the parameters: * * - `update_details` changing `email`: future emails, join link included, * go to the new address without the attendee being told. * - `confirm` with an unsettled payment order: it marks the order paid and * fires the payment-completed hooks. * * @param Booking $booking * @param string $action * @param array $params * @return bool */ private static function needsConfirmation(Booking $booking, $action, $params) { if (in_array($action, self::DESTRUCTIVE_ACTIONS, true)) { return true; } if ($action === 'update_details') { $fields = (array) Arr::get($params, 'fields', []); return array_key_exists('email', $fields); } if ($action === 'confirm') { return $booking->payment_method && $booking->payment_status !== 'paid' && $booking->payment_order; } return false; } /** * @return array|\WP_Error */ private static function execute(Booking $booking, $action, $params, $timezone) { if ($action === 'resend_email') { $result = BookingWriter::resendEmail($booking, sanitize_text_field(Arr::get($params, 'recipient', 'guest')), $params); if (is_wp_error($result)) { return $result; } return MCPHelper::success(['action' => $action] + $result, ['timezone' => $timezone]); } if ($action === 'update_details') { $result = BookingWriter::updateDetails($booking, Arr::get($params, 'fields', []), $params); } elseif ($action === 'reschedule') { $result = BookingWriter::reschedule($booking, $params); } else { $result = BookingWriter::applyStatus($booking, $action, $params); } if (is_wp_error($result)) { return $result; } return MCPHelper::success( [ 'action' => $action, 'booking' => BookingProjector::full($result, $timezone), ], [ 'timezone' => $timezone, 'notifications_requested' => BookingWriter::wantsNotifications($params), ] ); } /** * What a create would do, without doing it. Includes who would be emailed, * so the operator sees it before approving. * * @return array|\WP_Error */ private static function previewCreate(CalendarSlot $event, $params, $timezone) { // Same checks as execute, so a passing dry run means something. $valid = BookingWriter::validateCreate($event, $params); if (is_wp_error($valid)) { return $valid; } $notify = BookingWriter::wantsNotifications($params); $preview = [ 'action' => 'create', 'event' => [ 'id' => (int) $event->id, 'title' => $event->title, 'type' => $event->event_type, 'duration' => (int) $event->getDuration(Arr::get($params, 'duration')), 'status' => $event->status, ], 'attendee' => [ 'name' => sanitize_text_field(Arr::get($params, 'name', '')), 'email' => MCPHelper::maskEmail(Arr::get($params, 'email', '')), ], 'requested_start' => sanitize_text_field(Arr::get($params, 'start_time', '')), 'timezone' => $timezone, 'slot_available' => self::previewSlotAvailability($event, $params, $timezone), 'guests' => count((array) Arr::get($params, 'guests', [])), 'guests_bookable' => BookingWriter::previewGuestCount($event, $params), 'will_notify' => $notify ? self::recipientSummary($event) : [], 'notifications_requested' => $notify, 'note' => __('Availability is re-checked when you execute, so a slot taken in the meantime is refused rather than double-booked.', 'fluent-booking'), ]; if ($preview['slot_available'] === false) { $preview['note'] = __('That time is not currently free, so executing this would be refused. Call get-available-slots for the current openings.', 'fluent-booking'); } if ($ambiguity = MCPHelper::ambiguityNote(Arr::get($params, 'start_time', ''), $timezone)) { $preview['timezone_warning'] = $ambiguity; } return $preview; } /** * Whether the requested slot is free, for the preview only. Advisory: true * means "free a moment ago", not a reservation. null when unknown. * * @return bool|null */ private static function previewSlotAvailability(CalendarSlot $event, $params, $timezone) { $startTime = sanitize_text_field(Arr::get($params, 'start_time', '')); if (!$startTime) { return null; } try { $startUtc = MCPHelper::toUtc($startTime, $timezone); $check = SlotResolver::checkSlot( $event, $startUtc, $timezone, Arr::get($params, 'duration'), absint(Arr::get($params, 'host_id')) ?: null ); } catch (\Throwable $e) { return null; } if (is_wp_error($check) || !isset($check['available'])) { return null; } return (bool) $check['available']; } /** * @return array|\WP_Error */ private static function previewAction(Booking $booking, $action, $params, $timezone) { // Same state-machine check as execute, so we never mint a token for a // transition execute would refuse. $transitions = BookingWriter::statusTransitions(); if (isset($transitions[$action])) { $target = $transitions[$action]['to']; if ($booking->status === $target) { return MCPHelper::error( 'no_change', /* translators: %s: the booking's current status */ sprintf(__('This booking is already "%s".', 'fluent-booking'), $target), ['status' => $booking->status] ); } 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 return MCPHelper::error( 'not_yet_occurred', /* translators: %s: the requested status */ 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), ['status' => $booking->status, 'ends_at' => $booking->end_time] ); } if (!in_array($booking->status, $transitions[$action]['from'], true)) { return MCPHelper::error( 'invalid_transition', sprintf( /* translators: %1$s: current status, %2$s: requested status, %3$s: allowed statuses */ __('A booking that is "%1$s" cannot become "%2$s". Only %3$s bookings can.', 'fluent-booking'), $booking->status, $target, implode(', ', $transitions[$action]['from']) ), ['status' => $booking->status, 'allowed_from' => $transitions[$action]['from']] ); } } $notify = BookingWriter::wantsNotifications($params); $preview = [ 'action' => $action, 'booking' => BookingProjector::row($booking, $timezone), 'notifications_requested' => $notify, ]; if ($action === 'reschedule') { $preview['from'] = MCPHelper::timePair($booking->start_time, $timezone, 'current_start'); $preview['to'] = sanitize_text_field(Arr::get($params, 'start_time', '')); if (!$preview['to']) { return MCPHelper::error('missing_start_time', __('reschedule needs start_time.', 'fluent-booking')); } $preview['note'] = __('Availability is re-checked when you execute.', 'fluent-booking'); if ($ambiguity = MCPHelper::ambiguityNote($preview['to'], $timezone)) { $preview['timezone_warning'] = $ambiguity; } } if (in_array($action, ['cancel', 'reject'], true)) { $preview['reason'] = sanitize_text_field(Arr::get($params, 'reason', '')); $preview['refund_payment'] = Arr::isTrue($params, 'refund_payment'); if ($booking->payment_method && !$preview['refund_payment']) { $preview['payment_note'] = __('This booking was paid for. No refund will be issued unless you pass refund_payment.', 'fluent-booking'); } } if ($action === 'update_details') { $fields = (array) Arr::get($params, 'fields', []); if (!$fields) { return MCPHelper::error('missing_fields', __('update_details needs fields.', 'fluent-booking')); } $preview['changing'] = array_keys($fields); if (array_key_exists('email', $fields)) { $preview['email_change'] = [ 'from' => MCPHelper::maskEmail($booking->email), 'to' => MCPHelper::maskEmail(Arr::get($fields, 'email')), ]; $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'); } } if ($action === 'confirm' && $booking->payment_method && $booking->payment_status !== 'paid') { $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'); } $transitions = BookingWriter::statusTransitions(); if (isset($transitions[$action])) { $preview['status_change'] = [ 'from' => $booking->status, 'to' => $transitions[$action]['to'], ]; } if ($notify && $booking->calendar_event) { $preview['will_notify'] = self::recipientSummary($booking->calendar_event, $booking); } return $preview; } /** * Who an action would email, as roles rather than addresses, so a preview * the agent may echo back doesn't leak contact details. * * @return array */ private static function recipientSummary(CalendarSlot $event, $booking = null) { $recipients = []; $notifications = $event->getNotifications(); if (Arr::isTrue($notifications, 'booking_conf_attendee.enabled') || Arr::isTrue($notifications, 'booking_request_attendee.enabled')) { $recipients[] = $booking ? sprintf('attendee (%s)', MCPHelper::maskEmail($booking->email)) : 'attendee'; } if (Arr::isTrue($notifications, 'booking_conf_host.enabled') || Arr::isTrue($notifications, 'booking_request_host.enabled')) { $recipients[] = 'host'; } return $recipients; } /** * Fingerprint the event type, so deactivating or re-timing it between * preview and execute invalidates the token. * * @return string */ private static function createFingerprint(CalendarSlot $event) { return implode('|', [$event->id, $event->status, $event->duration, $event->updated_at]); } }