| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentBooking\App\Modules\MCP\Tools; |
| 4 |
|
| 5 |
use FluentBooking\App\Models\CalendarSlot; |
| 6 |
use FluentBooking\App\Modules\MCP\Support\AvailabilityDiagnostics; |
| 7 |
use FluentBooking\App\Modules\MCP\Support\MCPHelper; |
| 8 |
use FluentBooking\App\Modules\MCP\Support\PermissionGate; |
| 9 |
use FluentBooking\App\Modules\MCP\Support\SlotResolver; |
| 10 |
use FluentBooking\App\Services\PermissionManager; |
| 11 |
use FluentBooking\Framework\Support\Arr; |
| 12 |
|
| 13 |
defined('ABSPATH') || exit; |
| 14 |
|
| 15 |
/** |
| 16 |
* Availability — the highest-value read in the whole surface, and the one with |
| 17 |
* the tightest response budget. |
| 18 |
* |
| 19 |
* One tool, not two. A single-slot check ("is 2pm Tuesday free?") is the same |
| 20 |
* question with a narrower answer, so it is a `start_time` parameter here |
| 21 |
* rather than a second permanently-resident schema. |
| 22 |
*/ |
| 23 |
class SlotTools |
| 24 |
{ |
| 25 |
public static function definitions() |
| 26 |
{ |
| 27 |
return [ |
| 28 |
'fluent-booking/get-available-slots' => [ |
| 29 |
'label' => __('Get available slots', 'fluent-booking'), |
| 30 |
'description' => __('Bookable times for an event type, keyed by date in the requested timezone. Pass start_time instead of a range to check one specific slot. Uses the same engine as the public booking page.', 'fluent-booking'), |
| 31 |
'input_schema' => [ |
| 32 |
'type' => 'object', |
| 33 |
'properties' => [ |
| 34 |
'event_id' => [ |
| 35 |
'type' => 'integer', |
| 36 |
'description' => __('The event type to check.', 'fluent-booking'), |
| 37 |
], |
| 38 |
'from' => [ |
| 39 |
'type' => 'string', |
| 40 |
'description' => __('First date to check, Y-m-d. Defaults to today.', 'fluent-booking'), |
| 41 |
], |
| 42 |
'to' => [ |
| 43 |
'type' => 'string', |
| 44 |
'description' => __('Last date to check, Y-m-d. Defaults to 14 days out; 62 days maximum.', 'fluent-booking'), |
| 45 |
], |
| 46 |
'start_time' => [ |
| 47 |
'type' => 'string', |
| 48 |
'description' => __('Check one slot instead of a range: Y-m-d H:i:s in the given timezone.', 'fluent-booking'), |
| 49 |
], |
| 50 |
'timezone' => [ |
| 51 |
'type' => 'string', |
| 52 |
'description' => __('IANA timezone the times are returned in. Defaults to the site timezone.', 'fluent-booking'), |
| 53 |
], |
| 54 |
'duration' => [ |
| 55 |
'type' => 'integer', |
| 56 |
'description' => __('Minutes, for events that offer several durations. Defaults to the event default.', 'fluent-booking'), |
| 57 |
], |
| 58 |
'host_id' => [ |
| 59 |
'type' => 'integer', |
| 60 |
'description' => __('Restrict to one host on a team event.', 'fluent-booking'), |
| 61 |
], |
| 62 |
], |
| 63 |
'required' => ['event_id'], |
| 64 |
], |
| 65 |
'annotations' => [ |
| 66 |
'title' => __('Get available slots', 'fluent-booking'), |
| 67 |
'readonly' => true, |
| 68 |
], |
| 69 |
'permission_callback' => [PermissionGate::class, 'readGate'], |
| 70 |
'execute_callback' => [self::class, 'getSlots'], |
| 71 |
], |
| 72 |
|
| 73 |
'fluent-booking/diagnose-availability' => [ |
| 74 |
'label' => __('Diagnose availability', 'fluent-booking'), |
| 75 |
'description' => __('Explain why an event type is or is not offering slots. Returns every rule that can remove slots with its configured value, and attributes each empty date to the specific rule responsible.', 'fluent-booking'), |
| 76 |
'input_schema' => [ |
| 77 |
'type' => 'object', |
| 78 |
'properties' => [ |
| 79 |
'event_id' => [ |
| 80 |
'type' => 'integer', |
| 81 |
'description' => __('The event type to investigate.', 'fluent-booking'), |
| 82 |
], |
| 83 |
'from' => [ |
| 84 |
'type' => 'string', |
| 85 |
'description' => __('First date to investigate, Y-m-d. Defaults to today.', 'fluent-booking'), |
| 86 |
], |
| 87 |
'to' => [ |
| 88 |
'type' => 'string', |
| 89 |
'description' => __('Last date to investigate, Y-m-d. Defaults to 14 days out; 62 days maximum.', 'fluent-booking'), |
| 90 |
], |
| 91 |
'timezone' => [ |
| 92 |
'type' => 'string', |
| 93 |
'description' => __('IANA timezone the dates are interpreted in.', 'fluent-booking'), |
| 94 |
], |
| 95 |
'host_id' => [ |
| 96 |
'type' => 'integer', |
| 97 |
'description' => __('Investigate one host on a team event.', 'fluent-booking'), |
| 98 |
], |
| 99 |
], |
| 100 |
'required' => ['event_id'], |
| 101 |
], |
| 102 |
'annotations' => [ |
| 103 |
'title' => __('Diagnose availability', 'fluent-booking'), |
| 104 |
'readonly' => true, |
| 105 |
], |
| 106 |
'permission_callback' => [PermissionGate::class, 'readGate'], |
| 107 |
'execute_callback' => [self::class, 'diagnose'], |
| 108 |
], |
| 109 |
]; |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* @param array $params |
| 114 |
* @return array|\WP_Error |
| 115 |
*/ |
| 116 |
public static function diagnose($params = []) |
| 117 |
{ |
| 118 |
$event = self::resolveEvent($params); |
| 119 |
|
| 120 |
if (is_wp_error($event)) { |
| 121 |
return $event; |
| 122 |
} |
| 123 |
|
| 124 |
$timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); |
| 125 |
|
| 126 |
$range = SlotResolver::resolveRange( |
| 127 |
Arr::get($params, 'from', ''), |
| 128 |
Arr::get($params, 'to', '') |
| 129 |
); |
| 130 |
|
| 131 |
if (is_wp_error($range)) { |
| 132 |
return $range; |
| 133 |
} |
| 134 |
|
| 135 |
list($from, $to) = $range; |
| 136 |
|
| 137 |
$hostId = SlotResolver::validateHostId($event, Arr::get($params, 'host_id')); |
| 138 |
|
| 139 |
if (is_wp_error($hostId)) { |
| 140 |
return $hostId; |
| 141 |
} |
| 142 |
|
| 143 |
$report = AvailabilityDiagnostics::run($event, $from, $to, $timezone, $hostId); |
| 144 |
|
| 145 |
$failed = []; |
| 146 |
|
| 147 |
foreach ($report['checks'] as $check) { |
| 148 |
if (empty($check['passed'])) { |
| 149 |
$failed[] = $check['check']; |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
$nextStep = $failed |
| 154 |
? sprintf( |
| 155 |
/* translators: %s: comma-separated names of the configuration checks that failed */ |
| 156 |
__('These checks fail on their own: %s. Fix those before looking at individual dates.', 'fluent-booking'), |
| 157 |
implode(', ', $failed) |
| 158 |
) |
| 159 |
: ''; |
| 160 |
|
| 161 |
return MCPHelper::success($report, ['timezone' => $timezone], $nextStep); |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* @param array $params |
| 166 |
* @return array|\WP_Error |
| 167 |
*/ |
| 168 |
public static function getSlots($params = []) |
| 169 |
{ |
| 170 |
$event = self::resolveEvent($params); |
| 171 |
|
| 172 |
if (is_wp_error($event)) { |
| 173 |
return $event; |
| 174 |
} |
| 175 |
|
| 176 |
$timezone = MCPHelper::resolveTimezone(Arr::get($params, 'timezone', '')); |
| 177 |
$duration = absint(Arr::get($params, 'duration')) ?: null; |
| 178 |
|
| 179 |
$hostId = SlotResolver::validateHostId($event, Arr::get($params, 'host_id')); |
| 180 |
|
| 181 |
if (is_wp_error($hostId)) { |
| 182 |
return $hostId; |
| 183 |
} |
| 184 |
|
| 185 |
$startTime = sanitize_text_field((string) Arr::get($params, 'start_time', '')); |
| 186 |
|
| 187 |
if ($startTime) { |
| 188 |
// The same strict, DST-aware conversion create-booking uses. A bare |
| 189 |
// strtotime() truthiness check accepted "2026-08-24" (silently |
| 190 |
// meaning midnight, so the answer was "not available" and the agent |
| 191 |
// concluded the day was closed) and "next tuesday" (resolved |
| 192 |
// relative to now). Worse, it accepted strings create-booking then |
| 193 |
// rejected, so an agent could be told a slot was free and be unable |
| 194 |
// to book it with the same value. |
| 195 |
$startUtc = MCPHelper::toUtc($startTime, $timezone); |
| 196 |
|
| 197 |
if (is_wp_error($startUtc)) { |
| 198 |
return $startUtc; |
| 199 |
} |
| 200 |
|
| 201 |
$check = SlotResolver::checkSlot($event, $startUtc, $timezone, $duration, $hostId); |
| 202 |
|
| 203 |
if (is_wp_error($check)) { |
| 204 |
return $check; |
| 205 |
} |
| 206 |
|
| 207 |
return MCPHelper::success( |
| 208 |
array_merge(['event_id' => (int) $event->id], $check), |
| 209 |
['timezone' => $timezone] |
| 210 |
); |
| 211 |
} |
| 212 |
|
| 213 |
$range = SlotResolver::resolveRange( |
| 214 |
Arr::get($params, 'from', ''), |
| 215 |
Arr::get($params, 'to', '') |
| 216 |
); |
| 217 |
|
| 218 |
if (is_wp_error($range)) { |
| 219 |
return $range; |
| 220 |
} |
| 221 |
|
| 222 |
list($from, $to) = $range; |
| 223 |
|
| 224 |
$result = SlotResolver::getSlots($event, $from, $to, $timezone, $duration, $hostId); |
| 225 |
|
| 226 |
if (is_wp_error($result)) { |
| 227 |
return $result; |
| 228 |
} |
| 229 |
|
| 230 |
$data = array_merge( |
| 231 |
[ |
| 232 |
'event_id' => (int) $event->id, |
| 233 |
'event_type' => $event->event_type, |
| 234 |
'duration' => (int) $event->getDuration($duration), |
| 235 |
'from' => $from, |
| 236 |
'to' => $to, |
| 237 |
], |
| 238 |
$result |
| 239 |
); |
| 240 |
|
| 241 |
// Only worth stating when it actually constrains the answer; on an |
| 242 |
// indefinite range it is false and would just be noise. |
| 243 |
$maxLookup = $event->getMaxLookUpDate(); |
| 244 |
|
| 245 |
if ($maxLookup) { |
| 246 |
$data['bookable_until'] = $maxLookup; |
| 247 |
} |
| 248 |
|
| 249 |
$nextStep = ''; |
| 250 |
|
| 251 |
if (empty($result['slots'])) { |
| 252 |
$nextStep = __('No slots in this window. Call diagnose-availability with the same event_id to see which rule removed them.', 'fluent-booking'); |
| 253 |
} |
| 254 |
|
| 255 |
return MCPHelper::success($data, ['timezone' => $timezone], $nextStep); |
| 256 |
} |
| 257 |
|
| 258 |
/** |
| 259 |
* Load the event and confirm the caller may see it. |
| 260 |
* |
| 261 |
* Slots are public information on the booking page, but reaching them |
| 262 |
* through an authenticated operator tool implies acting on that calendar, so |
| 263 |
* the same read gate the admin uses applies here. |
| 264 |
* |
| 265 |
* @param array $params |
| 266 |
* @return CalendarSlot|\WP_Error |
| 267 |
*/ |
| 268 |
private static function resolveEvent($params) |
| 269 |
{ |
| 270 |
$eventId = absint(Arr::get($params, 'event_id')); |
| 271 |
|
| 272 |
if (!$eventId) { |
| 273 |
return MCPHelper::error( |
| 274 |
'missing_identifier', |
| 275 |
__('event_id is required. Call get-booking-context or get-event-types to find one.', 'fluent-booking') |
| 276 |
); |
| 277 |
} |
| 278 |
|
| 279 |
$event = CalendarSlot::find($eventId); |
| 280 |
|
| 281 |
if (!$event) { |
| 282 |
return MCPHelper::error( |
| 283 |
'event_not_found', |
| 284 |
__('No event type matched that id.', 'fluent-booking') |
| 285 |
); |
| 286 |
} |
| 287 |
|
| 288 |
if (!PermissionManager::canReadCalendar($event->calendar_id)) { |
| 289 |
return MCPHelper::error( |
| 290 |
'permission_denied', |
| 291 |
__('You do not have access to this event type.', 'fluent-booking') |
| 292 |
); |
| 293 |
} |
| 294 |
|
| 295 |
if (!$event->calendar) { |
| 296 |
return MCPHelper::error( |
| 297 |
'event_not_found', |
| 298 |
__('This event type has no calendar attached, so availability cannot be computed.', 'fluent-booking') |
| 299 |
); |
| 300 |
} |
| 301 |
|
| 302 |
return $event; |
| 303 |
} |
| 304 |
} |
| 305 |
|