PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
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 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Support / SlotResolver.php

SlotResolver.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Support/SlotResolver.php

376 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\App\Hooks\Handlers\TimeSlotServiceHandler;
6 use FluentBooking\App\Models\CalendarSlot;
7
8 defined('ABSPATH') || exit;
9
10 /**
11 * Thin wrapper over the slot engine for the MCP tools.
12 *
13 * Deliberately thin. Availability is the one answer an agent must never get
14 * differently from what a visitor sees on the booking page, so this class
15 * computes nothing: it resolves the same service the public page resolves
16 * (TimeSlotServiceHandler::initService, which returns the round-robin /
17 * collective / one-off / multi variants for Pro event types), calls the same
18 * method, and then only reshapes and trims the result.
19 *
20 * The reshaping is the point. getAvailableSpots() returns one array per slot,
21 * keyed by full timestamp — a 30-day window on a 30-minute event is ~480 of
22 * those, which is roughly 12k tokens of an agent's context for a single call.
23 * Keyed by date with bare "HH:MM" strings, the same information is about a
24 * tenth of that. See docs/mcp-server-spec.md §10.
25 */
26 class SlotResolver
27 {
28 /**
29 * Hard ceiling on a slot query, in days. A caller asking for a year of
30 * availability does not want a year of availability in one response; it
31 * wants a smaller question it has not thought of yet.
32 */
33 const MAX_RANGE_DAYS = 62;
34
35 /**
36 * Hard ceiling on slots in one response, enforced at a whole-date boundary.
37 *
38 * A 62-day window on a busy event is ~1,400 slots ≈ 3,500 tokens, which
39 * overruns the budget in docs/mcp-server-spec.md §10 by more than double.
40 * Measured at ~8.6 bytes per slot, 600 keeps a full response near 1,500
41 * tokens. The cap is never silent: the response says it truncated and names
42 * the first date it left out, so the agent asks for the next window instead
43 * of concluding the calendar ends there.
44 */
45 const MAX_SLOTS = 600;
46
47 /**
48 * Available slots for an event, keyed by date, in the requested timezone.
49 *
50 * @param CalendarSlot $event
51 * @param string $from 'Y-m-d' in $timezone
52 * @param string $to 'Y-m-d' in $timezone
53 * @param string $timezone resolved IANA identifier
54 * @param int|null $duration minutes; the event default when null
55 * @param int|null $hostId for team events, restrict to one host
56 * @return array|\WP_Error
57 */
58 public static function getSlots(CalendarSlot $event, $from, $to, $timezone, $duration = null, $hostId = null)
59 {
60 if ($event->status !== 'active') {
61 // The slot engine does not check event status — BookingController
62 // does, before it ever calls the engine. Without mirroring that here
63 // a draft event reports a full calendar of bookable times that the
64 // public page would refuse, and an agent would try to book into it.
65 return [
66 'slots' => [],
67 'reason' => sprintf(
68 /* translators: %s: the event type's current status */
69 __('The event type is "%s", not active, so it accepts no bookings.', 'fluent-booking'),
70 $event->status
71 ),
72 ];
73 }
74
75 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
76
77 if (is_wp_error($service)) {
78 return MCPHelper::error('slot_engine_unavailable', $service->get_error_message());
79 }
80
81 $slots = [];
82 $spotsRemaining = [];
83 $lastError = null;
84
85 // The engine is month-bounded: getAvailableSpots() derives its end date
86 // via getMaxBookableDateTime(), which clamps to the last day of the
87 // START date's month, because the booking page renders one month at a
88 // time. A single call for 23 Aug – 5 Sep therefore returns August only
89 // and reports nothing for September — which an agent reads as "fully
90 // booked" rather than "not asked". So walk the range a month at a time
91 // and merge. MAX_RANGE_DAYS keeps this to at most three calls.
92 $cursor = $from;
93
94 while ($cursor <= $to) {
95 $spots = $service->getAvailableSpots($cursor . ' 00:00:00', $timezone, $duration, $hostId);
96
97 if (is_wp_error($spots)) {
98 // One month being unusable (its window is past the event's
99 // bookable range) says nothing about the others — keep going and
100 // only surface the error if no month yields anything.
101 $lastError = $spots;
102 } else {
103 self::mergeMonth((array) $spots, $from, $to, $slots, $spotsRemaining);
104 }
105
106 $next = gmdate('Y-m-01', strtotime(gmdate('Y-m-01', strtotime($cursor)) . ' +1 month')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
107
108 // Guard against a non-advancing cursor: an infinite loop inside a
109 // request is worse than a wrong answer.
110 if ($next <= $cursor) {
111 break;
112 }
113
114 $cursor = $next;
115 }
116
117 if (!$slots && $lastError) {
118 return [
119 'slots' => [],
120 'reason' => $lastError->get_error_message(),
121 ];
122 }
123
124 ksort($slots);
125
126 return self::truncate($slots, $spotsRemaining);
127 }
128
129 /**
130 * Apply MAX_SLOTS at a whole-date boundary and say so when it bites.
131 *
132 * Whole dates rather than a flat slot count: half a Tuesday reads as a
133 * Tuesday that is half booked, which is a different and wrong answer.
134 *
135 * @param array $slots
136 * @param array $spotsRemaining
137 * @return array
138 */
139 private static function truncate($slots, $spotsRemaining)
140 {
141 $kept = [];
142 $count = 0;
143 $truncatedAt = '';
144
145 foreach ($slots as $date => $times) {
146 if ($count && ($count + count($times)) > self::MAX_SLOTS) {
147 $truncatedAt = $date;
148 break;
149 }
150
151 $kept[$date] = $times;
152 $count += count($times);
153 }
154
155 $result = ['slots' => $kept];
156
157 if ($spotsRemaining) {
158 $result['spots_remaining'] = array_intersect_key($spotsRemaining, $kept);
159 }
160
161 if ($truncatedAt) {
162 $result['truncated'] = true;
163 $result['truncated_at'] = $truncatedAt;
164 $result['truncation_note'] = sprintf(
165 /* translators: 1: the slot ceiling, 2: the first date omitted from the response */
166 __('Capped at %1$d slots. Dates from %2$s onward are not included — query again starting there.', 'fluent-booking'),
167 self::MAX_SLOTS,
168 $truncatedAt
169 );
170 }
171
172 return $result;
173 }
174
175 /**
176 * Fold one month's raw engine output into the accumulating result, trimmed
177 * to the requested window.
178 *
179 * getAvailableSpots() can also snap its start back to the first of the month,
180 * so the lower bound needs trimming as well as the upper.
181 *
182 * @param array $spots raw engine output
183 * @param string $from
184 * @param string $to
185 * @param array $slots accumulator, by reference
186 * @param array $spotsRemaining accumulator, by reference
187 */
188 private static function mergeMonth($spots, $from, $to, &$slots, &$spotsRemaining)
189 {
190 foreach ($spots as $date => $daySlots) {
191 if ($date < $from || $date > $to || !$daySlots) {
192 continue;
193 }
194
195 $times = isset($slots[$date]) ? $slots[$date] : [];
196
197 foreach ($daySlots as $slot) {
198 $start = isset($slot['start']) ? $slot['start'] : '';
199
200 if (!$start) {
201 continue;
202 }
203
204 $time = gmdate('H:i', strtotime($start)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
205
206 if (!in_array($time, $times, true)) {
207 $times[] = $time;
208 }
209
210 // `remaining` is false on every event that does not track spots,
211 // which is most of them. Only build the parallel map when there
212 // is something in it — an always-present map of nulls is pure
213 // context cost.
214 if (isset($slot['remaining']) && $slot['remaining'] !== false && $slot['remaining'] !== null) {
215 $spotsRemaining[$date][$time] = (int) $slot['remaining'];
216 }
217 }
218
219 if ($times) {
220 sort($times);
221 $slots[$date] = $times;
222 }
223 }
224 }
225
226 /**
227 * Is one specific slot bookable right now?
228 *
229 * Runs the same engine as getSlots() rather than scanning its output, so the
230 * answer reflects the state at the moment of asking — this is the check a
231 * write path relies on, and a cached list is exactly what it must not trust.
232 *
233 * @param CalendarSlot $event
234 * @param string $startUtc 'Y-m-d H:i:s' in UTC, already validated by
235 * MCPHelper::toUtc()
236 * @param string $timezone resolved IANA identifier, for the response
237 * @param int|null $duration minutes
238 * @param int|null $hostId
239 * @return array|\WP_Error
240 */
241 public static function checkSlot(CalendarSlot $event, $startUtc, $timezone, $duration = null, $hostId = null)
242 {
243 $service = TimeSlotServiceHandler::initService($event->calendar, $event);
244
245 if (is_wp_error($service)) {
246 return MCPHelper::error('slot_engine_unavailable', $service->get_error_message());
247 }
248
249 $duration = $event->getDuration($duration);
250
251 if ($event->status !== 'active') {
252 return array_merge(
253 [
254 'available' => false,
255 'duration' => (int) $duration,
256 'reason' => sprintf(
257 /* translators: %s: the event type's current status */
258 __('The event type is "%s", not active, so it accepts no bookings.', 'fluent-booking'),
259 $event->status
260 ),
261 ],
262 MCPHelper::timePair($startUtc, $timezone, 'start')
263 );
264 }
265
266 $endUtc = gmdate('Y-m-d H:i:s', strtotime($startUtc) + ($duration * 60)); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
267
268 $slot = $service->isSpotAvailable($startUtc, $endUtc, $duration, $hostId);
269
270 return array_merge(
271 [
272 'available' => (bool) $slot,
273 'duration' => (int) $duration,
274 ],
275 MCPHelper::timePair($startUtc, $timezone, 'start'),
276 MCPHelper::timePair($endUtc, $timezone, 'end')
277 );
278 }
279
280 /**
281 * @param CalendarSlot $event
282 * @param mixed $hostId
283 * @return int|null|\WP_Error
284 */
285 public static function validateHostId(CalendarSlot $event, $hostId)
286 {
287 return MCPHelper::resolveEventHost($event, $hostId);
288 }
289
290 /**
291 * Clamp a requested window to something answerable, defaulting to the next
292 * 14 days when the caller gives no bounds.
293 *
294 * @param string $from 'Y-m-d' or empty
295 * @param string $to 'Y-m-d' or empty
296 * @return array|\WP_Error [$from, $to]
297 */
298 public static function resolveRange($from, $to)
299 {
300 // Absent and unparseable are different questions. Both used to
301 // normalise to '', so `from: "next tuesday"` fell through to the
302 // default window and came back as a confident answer about the wrong
303 // fortnight. Matches BookingTools::dateRange().
304 foreach (['from' => $from, 'to' => $to] as $key => $value) {
305 if (self::suppliedDate($value) && !self::normalizeDate($value)) {
306 return MCPHelper::error(
307 'invalid_date',
308 __('from and to must be dates in Y-m-d form.', 'fluent-booking'),
309 ['parameter' => $key, 'received' => is_scalar($value) ? (string) $value : '']
310 );
311 }
312 }
313
314 $from = self::normalizeDate($from);
315 $to = self::normalizeDate($to);
316
317 if (!$from) {
318 $from = gmdate('Y-m-d'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
319 }
320
321 if (!$to) {
322 $to = gmdate('Y-m-d', strtotime($from . ' +13 days')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
323 }
324
325 if ($to < $from) {
326 return MCPHelper::error(
327 'invalid_range',
328 __('The end of the range is before its start.', 'fluent-booking')
329 );
330 }
331
332 $days = (strtotime($to) - strtotime($from)) / DAY_IN_SECONDS;
333
334 if ($days > self::MAX_RANGE_DAYS) {
335 return MCPHelper::error(
336 'range_too_large',
337 sprintf(
338 /* translators: %d: maximum number of days allowed in one availability query */
339 __('Availability can be queried %d days at a time. Ask for a narrower window.', 'fluent-booking'),
340 self::MAX_RANGE_DAYS
341 ),
342 ['max_days' => self::MAX_RANGE_DAYS]
343 );
344 }
345
346 return [$from, $to];
347 }
348
349 /**
350 * @param mixed $date
351 * @return bool whether the caller supplied anything at all
352 */
353 private static function suppliedDate($date)
354 {
355 return is_string($date) ? trim($date) !== '' : !empty($date);
356 }
357
358 /**
359 * @param mixed $date
360 * @return string 'Y-m-d', or '' when unparseable
361 */
362 private static function normalizeDate($date)
363 {
364 $date = is_string($date) ? trim($date) : '';
365
366 if (!$date) {
367 return '';
368 }
369
370 // Y-m-d only. strtotime() would also accept "next tuesday", "+1 year"
371 // and "5", resolving them against the current instant and answering a
372 // question nobody asked.
373 return MCPHelper::isRealDate($date) ? $date : '';
374 }
375 }
376