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 / Tools / SlotTools.php

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

305 lines 11.5 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\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