PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.6
Booking for Appointments and Events Calendar – Amelia v2.4.6
2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / src / Infrastructure / WP / MCP / AmeliaAbilitiesRegistrar.php
ameliabooking / src / Infrastructure / WP / MCP Last commit date
AmeliaAbilitiesRegistrar.php 2 months ago AmeliaMcpServerRegistrar.php 3 months ago
AmeliaAbilitiesRegistrar.php
1478 lines
1 <?php
2
3 namespace AmeliaBooking\Infrastructure\WP\MCP;
4
5 use AmeliaBooking\Application\Controller\Bookable\Service\AddServiceController;
6 use AmeliaBooking\Application\Controller\Bookable\Service\GetServicesController;
7 use AmeliaBooking\Application\Controller\Booking\Appointment\AddAppointmentController;
8 use AmeliaBooking\Application\Controller\Booking\Appointment\AddBookingController;
9 use AmeliaBooking\Application\Controller\Booking\Appointment\GetAppointmentsController;
10 use AmeliaBooking\Application\Controller\Booking\Appointment\GetTimeSlotsController;
11 use AmeliaBooking\Application\Controller\Booking\Appointment\UpdateBookingStatusController;
12 use AmeliaBooking\Application\Controller\Booking\Event\AddEventController;
13 use AmeliaBooking\Application\Controller\Booking\Event\GetEventsController;
14 use AmeliaBooking\Application\Controller\Booking\Event\UpdateEventBookingController;
15 use AmeliaBooking\Application\Controller\User\Customer\AddCustomerController;
16 use AmeliaBooking\Application\Controller\User\Customer\GetCustomersController;
17 use AmeliaBooking\Application\Controller\User\Provider\GetProvidersController;
18 use AmeliaBooking\Domain\Entity\Entities;
19 use AmeliaBooking\Domain\Entity\User\AbstractUser;
20 use AmeliaVendor\Psr\Http\Message\ServerRequestInterface as Request;
21 use AmeliaVendor\Psr\Http\Message\ResponseInterface as Response;
22 use Slim\Psr7\Factory\ResponseFactory;
23 use Slim\Psr7\Factory\ServerRequestFactory;
24 use WP_Error;
25
26 class AmeliaAbilitiesRegistrar
27 {
28 private const MCP_LIST_DEFAULT_LIMIT = 25;
29
30 private const MCP_LIST_MAX_LIMIT = 500;
31
32 private const MCP_DEFAULT_DATE_RANGE_DAYS = 90;
33
34 /**
35 * Register Amelia ability categories.
36 */
37 public static function registerCategories(): void
38 {
39 wp_register_ability_category('amelia-read', array(
40 'label' => __('Amelia – Read', 'wpamelia'),
41 'description' => __(
42 'Read-only abilities for the Amelia booking system. Canonical workflow: ' .
43 '1) amelia/list-services — get service IDs and prices; ' .
44 '2) amelia/list-employees — get provider IDs for that service; ' .
45 '3) amelia/check-availability — find open timeslots; ' .
46 '4) amelia/list-customers (or amelia/add-customer) — resolve the customer ID; ' .
47 'then hand off to an amelia-write ability to complete the booking.',
48 'wpamelia'
49 ),
50 ));
51
52 wp_register_ability_category('amelia-write', array(
53 'label' => __('Amelia – Write', 'wpamelia'),
54 'description' => __(
55 'Write abilities for the Amelia booking system. Always gather required IDs from amelia-read abilities first, ' .
56 'then confirm all details with the user before calling a write ability. ' .
57 'Available actions: amelia/create-appointment (one-to-one service booking), ' .
58 'amelia/book-event (group event registration), ' .
59 'amelia/add-service, amelia/add-customer, amelia/create-event, amelia/cancel-booking.',
60 'wpamelia'
61 ),
62 ));
63 }
64
65 /**
66 * Register all Amelia abilities.
67 */
68 public static function registerAbilities(): void
69 {
70 static::registerListServicesAbility();
71 static::registerListEmployeesAbility();
72 static::registerListCustomersAbility();
73 static::registerListEventsAbility();
74 static::registerListAppointmentsAbility();
75 static::registerCheckAvailabilityAbility();
76 static::registerAddServiceAbility();
77 static::registerAddCustomerAbility();
78 static::registerCreateAppointmentAbility();
79 static::registerCreateEventAbility();
80 static::registerBookEventAbility();
81 static::registerCancelBookingAbility();
82 }
83
84 /**
85 * Build a bootstrapped container for command dispatch.
86 *
87 * @return \AmeliaBooking\Infrastructure\Common\Container
88 */
89 protected static function getContainer()
90 {
91 return require AMELIA_PATH . '/src/Infrastructure/ContainerConfig/container.php';
92 }
93
94 /**
95 * Route a request through the real Slim controller so that emitSuccessEvent()
96 * fires naturally — triggering notifications and all post-booking integrations.
97 *
98 * @param string $controllerClass Fully-qualified controller class name.
99 * @param array $params Request params: body for POST, query string for GET.
100 * @param array $args Route arguments (e.g. ['id' => 123] for path parameters).
101 * @param string $method HTTP method ('POST' or 'GET'). Defaults to 'POST'.
102 * @return mixed|\WP_Error The 'data' payload from the JSON response, or WP_Error on failure.
103 */
104 protected static function invokeApplication(string $controllerClass, array $params, array $args = [], string $method = 'POST')
105 {
106 $container = static::getContainer();
107
108 $serverRequestFactory = new ServerRequestFactory();
109 $uri = 'http://127.0.0.1/';
110 if ($method === 'GET' && $params !== []) {
111 $uri .= '?' . http_build_query($params);
112 }
113
114 $request = $serverRequestFactory->createServerRequest($method, $uri)
115 ->withHeader('Content-Type', 'application/json');
116
117 if ($method !== 'GET') {
118 $request = $request->withParsedBody($params);
119 }
120
121 $response = (new ResponseFactory())->createResponse();
122
123 $controller = new $controllerClass($container);
124
125 /** @var Response $result */
126 $result = $controller(
127 $request,
128 $response,
129 $args,
130 true // $validApiCall = true → skips nonce validation
131 );
132
133 $statusCode = $result->getStatusCode();
134 $decoded = json_decode((string) $result->getBody(), true);
135
136 if ($statusCode >= 400) {
137 $message = isset($decoded['message']) ? $decoded['message'] : __('Command failed.', 'wpamelia');
138 $code = $statusCode === 403 ? 'amelia_access_denied' : 'amelia_command_error';
139 return new WP_Error($code, $message, array('status' => $statusCode));
140 }
141
142 return isset($decoded['data']) ? $decoded['data'] : $decoded;
143 }
144
145 // ---------------------------------------------------------------------------
146 // READ abilities
147 // ---------------------------------------------------------------------------
148
149 protected static function registerListServicesAbility(): void
150 {
151 wp_register_ability('amelia/list-services', array(
152 'label' => __('List Services', 'wpamelia'),
153 'description' => __(
154 'Use when the user asks what services are available, what can be booked, appointment types,' .
155 ' or pricing. Returns bookable services with IDs, durations, and prices.' .
156 ' Results are paginated (use page and limit; default 25 per page).' .
157 ' Call this first before amelia/check-availability or amelia/create-appointment.',
158 'wpamelia'
159 ),
160 'category' => 'amelia-read',
161 'input_schema' => array(
162 'type' => 'object',
163 'default' => array(),
164 'properties' => array(
165 'limit' => array(
166 'type' => 'integer',
167 'minimum' => 1,
168 'maximum' => 500,
169 'description' => 'Number of services per page (default 25, max 500). Use with page for pagination.',
170 ),
171 'page' => array(
172 'type' => 'integer',
173 'minimum' => 1,
174 'description' => 'Page number for pagination (default 1)',
175 ),
176 'search' => array(
177 'type' => 'string',
178 'description' => 'Search services by name',
179 ),
180 ),
181 'additionalProperties' => false,
182 ),
183 'output_schema' => array(
184 'type' => 'object',
185 'properties' => array(
186 'services' => array(
187 'type' => 'array',
188 'items' => array(
189 'type' => 'object',
190 'properties' => array(
191 'id' => array('type' => 'integer', 'description' => 'Service ID'),
192 'name' => array('type' => 'string'),
193 'description' => array('type' => array('string', 'null')),
194 'color' => array('type' => 'string', 'description' => 'Hex color, e.g. "#1788FB"'),
195 'price' => array('type' => 'number'),
196 'duration' => array('type' => 'integer', 'description' => 'Duration in seconds'),
197 'minCapacity' => array('type' => 'integer'),
198 'maxCapacity' => array('type' => 'integer'),
199 'categoryId' => array('type' => 'integer'),
200 'status' => array('type' => 'string', 'description' => '"visible" or "hidden"'),
201 'show' => array('type' => 'boolean'),
202 'extras' => array('type' => 'array', 'items' => array('type' => 'object')),
203 ),
204 ),
205 ),
206 'countFiltered' => array('type' => 'integer'),
207 'countTotalByCategory' => array('type' => 'integer'),
208 'countTotal' => array('type' => 'integer'),
209 ),
210 ),
211 'execute_callback' => function ($input) {
212 $input = is_array($input) ? $input : array();
213 $params = array_merge(
214 array('sort' => 'idAsc'),
215 AmeliaAbilitiesRegistrar::getMcpListPaginationParams($input)
216 );
217
218 if (!empty($input['search'])) {
219 $params['search'] = sanitize_text_field($input['search']);
220 }
221
222 return AmeliaAbilitiesRegistrar::invokeApplication(GetServicesController::class, $params, [], 'GET');
223 },
224 'permission_callback' => function () {
225 return AmeliaAbilitiesRegistrar::canListServices();
226 },
227 'meta' => array(
228 'annotations' => array(
229 'readonly' => true,
230 'destructive' => false,
231 ),
232 'show_in_rest' => true,
233 'mcp' => array('public' => true),
234 ),
235 ));
236 }
237
238 protected static function registerListEmployeesAbility(): void
239 {
240 wp_register_ability('amelia/list-employees', array(
241 'label' => __('List Employees', 'wpamelia'),
242 'description' => __(
243 'Use when the user asks about staff, employees, therapists, or who performs a service.' .
244 ' Returns provider IDs required by amelia/create-appointment and amelia/check-availability.' .
245 ' Results are paginated (use page and limit; default 25 per page).',
246 'wpamelia'
247 ),
248 'category' => 'amelia-read',
249 'input_schema' => array(
250 'type' => 'object',
251 'default' => array(),
252 'properties' => array(
253 'limit' => array(
254 'type' => 'integer',
255 'minimum' => 1,
256 'maximum' => 500,
257 'description' => 'Number of employees per page (default 25, max 500). Use with page for pagination.',
258 ),
259 'page' => array(
260 'type' => 'integer',
261 'minimum' => 1,
262 'description' => 'Page number for pagination (default 1)',
263 ),
264 'search' => array(
265 'type' => 'string',
266 'description' => 'Search employees by name',
267 ),
268 ),
269 'additionalProperties' => false,
270 ),
271 'output_schema' => array(
272 'type' => 'object',
273 'properties' => array(
274 'users' => array(
275 'type' => 'array',
276 'items' => array(
277 'type' => 'object',
278 'properties' => array(
279 'id' => array('type' => 'integer', 'description' => 'Provider/employee ID'),
280 'firstName' => array('type' => 'string'),
281 'lastName' => array('type' => 'string'),
282 'email' => array('type' => array('string', 'null')),
283 'phone' => array('type' => array('string', 'null')),
284 'type' => array('type' => 'string', 'description' => 'Always "provider"'),
285 'status' => array('type' => 'string', 'description' => '"visible" or "hidden"'),
286 'locationId' => array('type' => array('integer', 'null')),
287 'timeZone' => array('type' => array('string', 'null')),
288 'serviceList' => array(
289 'type' => 'array',
290 'items' => array('type' => 'object'),
291 'description' => 'Services this provider delivers',
292 ),
293 'pictureFullPath' => array('type' => array('string', 'null')),
294 ),
295 ),
296 ),
297 'countFiltered' => array('type' => 'integer'),
298 'countTotal' => array('type' => 'integer'),
299 ),
300 ),
301 'execute_callback' => function ($input) {
302 $input = is_array($input) ? $input : array();
303 $params = AmeliaAbilitiesRegistrar::getMcpListPaginationParams($input);
304
305 if (!empty($input['search'])) {
306 $params['search'] = sanitize_text_field($input['search']);
307 }
308
309 return AmeliaAbilitiesRegistrar::invokeApplication(GetProvidersController::class, $params, [], 'GET');
310 },
311 'permission_callback' => function () {
312 return current_user_can('amelia_read_employees');
313 },
314 'meta' => array(
315 'annotations' => array(
316 'readonly' => true,
317 'destructive' => false,
318 ),
319 'show_in_rest' => true,
320 'mcp' => array('public' => true),
321 ),
322 ));
323 }
324
325 protected static function registerListCustomersAbility(): void
326 {
327 wp_register_ability('amelia/list-customers', array(
328 'label' => __('List Customers', 'wpamelia'),
329 'description' => __(
330 'Use when the user asks to look up a customer, find a client, or before booking on behalf' .
331 ' of an existing person. Returns customer IDs required by amelia/create-appointment and amelia/book-event.' .
332 ' Results are paginated (use page and limit; default 25 per page).',
333 'wpamelia'
334 ),
335 'category' => 'amelia-read',
336 'input_schema' => array(
337 'type' => 'object',
338 'default' => array(),
339 'properties' => array(
340 'limit' => array(
341 'type' => 'integer',
342 'minimum' => 1,
343 'maximum' => 500,
344 'description' => 'Number of customers per page (default 25, max 500). Use with page for pagination.',
345 ),
346 'search' => array(
347 'type' => 'string',
348 'description' => 'Search customers by name or email',
349 ),
350 'page' => array(
351 'type' => 'integer',
352 'minimum' => 1,
353 'description' => 'Page number for pagination (default 1)',
354 ),
355 ),
356 'additionalProperties' => false,
357 ),
358 'output_schema' => array(
359 'type' => 'object',
360 'properties' => array(
361 'users' => array(
362 'type' => 'array',
363 'items' => array(
364 'type' => 'object',
365 'properties' => array(
366 'id' => array('type' => 'integer', 'description' => 'Customer ID'),
367 'firstName' => array('type' => 'string'),
368 'lastName' => array('type' => 'string'),
369 'email' => array('type' => array('string', 'null')),
370 'phone' => array('type' => array('string', 'null')),
371 'type' => array('type' => 'string', 'description' => 'Always "customer"'),
372 'status' => array('type' => 'string'),
373 'gender' => array('type' => array('string', 'null')),
374 'note' => array('type' => array('string', 'null')),
375 ),
376 ),
377 ),
378 'filteredCount' => array('type' => 'integer'),
379 'totalCount' => array('type' => 'integer'),
380 ),
381 ),
382 'execute_callback' => function ($input) {
383 $input = is_array($input) ? $input : array();
384 $params = AmeliaAbilitiesRegistrar::getMcpListPaginationParams($input);
385
386 if (!empty($input['search'])) {
387 $params['search'] = sanitize_text_field($input['search']);
388 }
389
390 return AmeliaAbilitiesRegistrar::invokeApplication(GetCustomersController::class, $params, [], 'GET');
391 },
392 'permission_callback' => function () {
393 return AmeliaAbilitiesRegistrar::canListCustomers();
394 },
395 'meta' => array(
396 'annotations' => array(
397 'readonly' => true,
398 'destructive' => false,
399 ),
400 'show_in_rest' => true,
401 'mcp' => array('public' => true),
402 ),
403 ));
404 }
405
406 protected static function registerListEventsAbility(): void
407 {
408 wp_register_ability('amelia/list-events', array(
409 'label' => __('List Events', 'wpamelia'),
410 'description' => __(
411 'Use when the user asks about events, classes, workshops, or group sessions.' .
412 ' Returns event IDs, periods, capacity, and availability. Call this before amelia/book-event.' .
413 ' Results are paginated (use page and limit). When dates are omitted, only events from today through the next 90 days are returned.',
414 'wpamelia'
415 ),
416 'category' => 'amelia-read',
417 'input_schema' => array(
418 'type' => 'object',
419 'default' => array(),
420 'properties' => array(
421 'limit' => array(
422 'type' => 'integer',
423 'minimum' => 1,
424 'maximum' => 500,
425 'description' => 'Number of events per page (default 25, max 500). Use with page for pagination.',
426 ),
427 'search' => array(
428 'type' => 'string',
429 'description' => 'Search events by name',
430 ),
431 'page' => array(
432 'type' => 'integer',
433 'minimum' => 1,
434 'description' => 'Page number for pagination (default 1)',
435 ),
436 'dates' => array(
437 'type' => 'array',
438 'items' => array('type' => 'string'),
439 'maxItems' => 2,
440 'description' => 'Optional date range as [startDate, endDate] in YYYY-MM-DD format. Defaults to today through 90 days ahead.',
441 ),
442 ),
443 'additionalProperties' => false,
444 ),
445 'output_schema' => array(
446 'type' => 'object',
447 'properties' => array(
448 'events' => array(
449 'type' => 'array',
450 'items' => array(
451 'type' => 'object',
452 'properties' => array(
453 'id' => array('type' => 'integer', 'description' => 'Event ID'),
454 'name' => array('type' => 'string'),
455 'description' => array('type' => array('string', 'null')),
456 'color' => array('type' => array('string', 'null')),
457 'price' => array('type' => 'number'),
458 'maxCapacity' => array('type' => 'integer'),
459 'status' => array(
460 'type' => 'string',
461 'description' => 'Computed display status: "open", "closed", "full", "upcoming", "canceled"',
462 ),
463 'show' => array('type' => 'boolean'),
464 'locationId' => array('type' => array('integer', 'null')),
465 'bookedSpots' => array('type' => 'integer', 'description' => 'Number of confirmed bookings'),
466 'places' => array('type' => 'integer', 'description' => 'Remaining capacity (maxCapacity - bookedSpots)'),
467 'full' => array('type' => 'boolean'),
468 'periods' => array(
469 'type' => 'array',
470 'items' => array(
471 'type' => 'object',
472 'properties' => array(
473 'periodStart' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm'),
474 'periodEnd' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm'),
475 ),
476 ),
477 ),
478 'tags' => array('type' => 'array', 'items' => array('type' => 'object')),
479 ),
480 ),
481 ),
482 'count' => array('type' => array('integer', 'null'), 'description' => 'Events matching filters on the current page query'),
483 'countTotal' => array('type' => array('integer', 'null'), 'description' => 'Total events in the system'),
484 ),
485 ),
486 'execute_callback' => function ($input) {
487 $input = is_array($input) ? $input : array();
488 $params = array_merge(
489 AmeliaAbilitiesRegistrar::getMcpListPaginationParams($input),
490 array('bookings' => false)
491 );
492
493 if (!empty($input['search'])) {
494 $params['search'] = sanitize_text_field($input['search']);
495 }
496 if (!empty($input['dates']) && is_array($input['dates'])) {
497 $params['dates'] = array_map('sanitize_text_field', $input['dates']);
498 } else {
499 $params['dates'] = AmeliaAbilitiesRegistrar::getDefaultMcpDateRange();
500 }
501
502 return AmeliaAbilitiesRegistrar::invokeApplication(GetEventsController::class, $params, [], 'GET');
503 },
504 'permission_callback' => function () {
505 return current_user_can('amelia_read_events');
506 },
507 'meta' => array(
508 'annotations' => array(
509 'readonly' => true,
510 'destructive' => false,
511 ),
512 'show_in_rest' => true,
513 'mcp' => array('public' => true),
514 ),
515 ));
516 }
517
518 protected static function registerListAppointmentsAbility(): void
519 {
520 wp_register_ability('amelia/list-appointments', array(
521 'label' => __('List Appointments', 'wpamelia'),
522 'description' => __(
523 'Use when the user asks about existing appointments, upcoming bookings, or wants to find' .
524 ' a specific appointment to reschedule or cancel. Returns appointments with IDs, dates,' .
525 ' service, provider, customer, and status. Supports filtering by date range, provider,' .
526 ' service, customer, and status. Results are paginated (use page and limit; default 25 per page).' .
527 ' When dates are omitted, only appointments from today through the next 90 days are returned.',
528 'wpamelia'
529 ),
530 'category' => 'amelia-read',
531 'input_schema' => array(
532 'type' => 'object',
533 'default' => array(),
534 'properties' => array(
535 'dates' => array(
536 'type' => 'array',
537 'items' => array('type' => 'string'),
538 'maxItems' => 2,
539 'description' => 'Optional date range as [startDate, endDate] in YYYY-MM-DD format. Defaults to today through 90 days ahead.',
540 ),
541 'limit' => array(
542 'type' => 'integer',
543 'minimum' => 1,
544 'maximum' => 500,
545 'description' => 'Number of appointments per page (default 25, max 500). Use with page for pagination.',
546 ),
547 'providers' => array(
548 'type' => 'array',
549 'items' => array('type' => 'integer'),
550 'description' => 'Filter by provider/employee IDs. Use amelia/list-employees to find IDs.',
551 ),
552 'services' => array(
553 'type' => 'array',
554 'items' => array('type' => 'integer'),
555 'description' => 'Filter by service IDs. Use amelia/list-services to find IDs.',
556 ),
557 'customers' => array(
558 'type' => 'array',
559 'items' => array('type' => 'integer'),
560 'description' => 'Filter by customer IDs. Use amelia/list-customers to find IDs.',
561 ),
562 'status' => array(
563 'type' => 'string',
564 'enum' => array('approved', 'pending', 'canceled', 'rejected', 'no-show'),
565 'description' => 'Filter by appointment status.',
566 ),
567 'search' => array(
568 'type' => 'string',
569 'description' => 'Free-text search across service, provider, and customer names.',
570 ),
571 'page' => array(
572 'type' => 'integer',
573 'minimum' => 1,
574 'description' => 'Page number for pagination (default 1).',
575 ),
576 ),
577 'additionalProperties' => false,
578 ),
579 'output_schema' => array(
580 'type' => 'object',
581 'properties' => array(
582 'appointments' => array(
583 'type' => 'object',
584 'description' => 'Appointments grouped by date (YYYY-MM-DD). Each key contains { date, appointments[] }.',
585 'additionalProperties' => array(
586 'type' => 'object',
587 'properties' => array(
588 'date' => array('type' => 'string', 'description' => 'YYYY-MM-DD'),
589 'appointments' => array(
590 'type' => 'array',
591 'items' => array(
592 'type' => 'object',
593 'properties' => array(
594 'id' => array('type' => 'integer', 'description' => 'Appointment ID'),
595 'serviceId' => array('type' => 'integer'),
596 'providerId' => array('type' => 'integer'),
597 'locationId' => array('type' => array('integer', 'null')),
598 'bookingStart' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
599 'bookingEnd' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
600 'status' => array(
601 'type' => 'string',
602 'description' => '"approved", "pending", "canceled", "rejected", "no-show"',
603 ),
604 'internalNotes' => array('type' => array('string', 'null')),
605 'cancelable' => array('type' => 'boolean'),
606 'reschedulable' => array('type' => 'boolean'),
607 'bookings' => array(
608 'type' => 'array',
609 'items' => array(
610 'type' => 'object',
611 'properties' => array(
612 'id' => array(
613 'type' => 'integer',
614 'description' => 'CustomerBooking ID — use as bookingId for amelia/cancel-booking',
615 ),
616 'customerId' => array('type' => 'integer'),
617 'persons' => array('type' => 'integer'),
618 'status' => array('type' => 'string'),
619 'price' => array('type' => 'number'),
620 ),
621 ),
622 ),
623 ),
624 ),
625 ),
626 ),
627 ),
628 ),
629 'filteredCount' => array('type' => 'integer', 'description' => 'Appointments matching the current filters'),
630 'totalCount' => array('type' => 'integer'),
631 'totalApproved' => array('type' => 'integer'),
632 'totalPending' => array('type' => 'integer'),
633 ),
634 ),
635 'execute_callback' => function ($input) {
636 $input = is_array($input) ? $input : array();
637 $params = array_merge(
638 array('asArray' => false),
639 AmeliaAbilitiesRegistrar::getMcpListPaginationParams($input)
640 );
641
642 if (!empty($input['dates']) && is_array($input['dates'])) {
643 $params['dates'] = array_map('sanitize_text_field', $input['dates']);
644 } else {
645 $params['dates'] = AmeliaAbilitiesRegistrar::getDefaultMcpDateRange();
646 }
647 if (!empty($input['providers'])) {
648 $params['providers'] = array_map('intval', (array) $input['providers']);
649 }
650 if (!empty($input['services'])) {
651 $params['services'] = array_map('intval', (array) $input['services']);
652 }
653 if (!empty($input['customers'])) {
654 $params['customers'] = array_map('intval', (array) $input['customers']);
655 }
656 if (!empty($input['status'])) {
657 $params['status'] = sanitize_text_field($input['status']);
658 }
659 if (!empty($input['search'])) {
660 $params['search'] = sanitize_text_field($input['search']);
661 }
662
663 return AmeliaAbilitiesRegistrar::invokeApplication(GetAppointmentsController::class, $params, [], 'GET');
664 },
665 'permission_callback' => function () {
666 return current_user_can('amelia_read_appointments');
667 },
668 'meta' => array(
669 'annotations' => array(
670 'readonly' => true,
671 'destructive' => false,
672 ),
673 'show_in_rest' => true,
674 'mcp' => array('public' => true),
675 ),
676 ));
677 }
678
679 protected static function registerCheckAvailabilityAbility(): void
680 {
681 wp_register_ability('amelia/check-availability', array(
682 'label' => __('Check Availability', 'wpamelia'),
683 'description' => __(
684 'Use when the user asks when they can book, what slots are free, or what times are available' .
685 ' for a service. Returns available datetimes grouped by date. Requires serviceId from amelia/list-services.',
686 'wpamelia'
687 ),
688 'category' => 'amelia-read',
689 'input_schema' => array(
690 'type' => 'object',
691 'properties' => array(
692 'serviceId' => array(
693 'type' => 'integer',
694 'description' => 'Service ID (required). Use amelia/list-services to find IDs.',
695 ),
696 'persons' => array(
697 'type' => 'integer',
698 'minimum' => 1,
699 'description' => 'Number of persons (required). Default: 1',
700 ),
701 'startDateTime' => array(
702 'type' => 'string',
703 'description' => 'From which date/time to retrieve slots, in YYYY-MM-DD HH:mm format. Defaults to now.',
704 ),
705 'endDateTime' => array(
706 'type' => 'string',
707 'description' => 'Up until which date/time to retrieve slots, in YYYY-MM-DD HH:mm format.',
708 ),
709 'providerIds' => array(
710 'type' => 'array',
711 'items' => array('type' => 'integer'),
712 'description' => 'Filter by specific employee IDs. Use amelia/list-employees to find IDs.',
713 ),
714 'locationId' => array(
715 'type' => 'integer',
716 'description' => 'Filter by location ID.',
717 ),
718 'serviceDuration' => array(
719 'type' => 'integer',
720 'description' => 'Override the service duration in seconds.',
721 ),
722 'excludeAppointmentId' => array(
723 'type' => 'integer',
724 'description' => 'Exclude this appointment ID from calculations (used when rescheduling).',
725 ),
726 'extras' => array(
727 'type' => 'array',
728 'items' => array(
729 'type' => 'object',
730 'properties' => array(
731 'id' => array('type' => 'integer'),
732 'quantity' => array('type' => 'integer', 'minimum' => 1),
733 ),
734 'required' => array('id', 'quantity'),
735 ),
736 'description' => 'Extras to include in slot calculation.',
737 ),
738 ),
739 'required' => array('serviceId', 'persons'),
740 'additionalProperties' => false,
741 ),
742 'output_schema' => array(
743 'type' => 'object',
744 'properties' => array(
745 'minimum' => array('type' => 'string', 'description' => 'Earliest bookable datetime in YYYY-MM-DD HH:mm format'),
746 'maximum' => array('type' => 'string', 'description' => 'Latest bookable datetime in YYYY-MM-DD HH:mm format'),
747 'duration' => array('type' => 'integer', 'description' => 'Computed service duration in seconds'),
748 'slots' => array(
749 'type' => 'object',
750 'description' => 'Available slots keyed by date (YYYY-MM-DD), then by time (HH:mm),' .
751 ' each value is an array of provider ID arrays, e.g. { "2025-06-01": { "09:00": [[3]] } }',
752 ),
753 'occupied' => array(
754 'type' => 'object',
755 'description' => 'Same structure as slots but for unavailable times',
756 ),
757 'busyness' => array(
758 'type' => 'object',
759 'description' => 'Per-day occupancy percentage (0-100) keyed by date (YYYY-MM-DD)',
760 ),
761 ),
762 ),
763 'execute_callback' => function ($input) {
764 $input = is_array($input) ? $input : array();
765 $params = array(
766 'serviceId' => (int) $input['serviceId'],
767 'persons' => isset($input['persons']) ? max(1, (int) $input['persons']) : 1,
768 );
769
770 if (!empty($input['startDateTime'])) {
771 $params['startDateTime'] = sanitize_text_field($input['startDateTime']);
772 }
773 if (!empty($input['endDateTime'])) {
774 $params['endDateTime'] = sanitize_text_field($input['endDateTime']);
775 }
776 if (!empty($input['providerIds'])) {
777 $params['providerIds'] = array_map('intval', (array) $input['providerIds']);
778 }
779 if (!empty($input['locationId'])) {
780 $params['locationId'] = (int) $input['locationId'];
781 }
782 if (!empty($input['serviceDuration'])) {
783 $params['serviceDuration'] = (int) $input['serviceDuration'];
784 }
785 if (!empty($input['excludeAppointmentId'])) {
786 $params['excludeAppointmentId'] = (int) $input['excludeAppointmentId'];
787 }
788 if (!empty($input['extras'])) {
789 $params['extras'] = json_encode($input['extras']);
790 }
791
792 return AmeliaAbilitiesRegistrar::invokeApplication(GetTimeSlotsController::class, $params, [], 'GET');
793 },
794 'permission_callback' => function () {
795 return AmeliaAbilitiesRegistrar::canListServices();
796 },
797 'meta' => array(
798 'annotations' => array(
799 'readonly' => true,
800 'destructive' => false,
801 ),
802 'show_in_rest' => true,
803 'mcp' => array('public' => true),
804 ),
805 ));
806 }
807
808 // ---------------------------------------------------------------------------
809 // WRITE abilities
810 // ---------------------------------------------------------------------------
811
812 protected static function registerAddServiceAbility(): void
813 {
814 wp_register_ability('amelia/add-service', array(
815 'label' => __('Add Service', 'wpamelia'),
816 'description' => __(
817 'Use when the user wants to add, create, or set up a new bookable service.' .
818 ' ALWAYS confirm the service name, duration, price, and assigned employees with the user before calling this ability.',
819 'wpamelia'
820 ),
821 'category' => 'amelia-write',
822 'input_schema' => array(
823 'type' => 'object',
824 'properties' => array(
825 'name' => array(
826 'type' => 'string',
827 'description' => 'Service name (required). Ask the user to confirm this value before calling the ability.',
828 ),
829 'categoryId' => array('type' => 'integer', 'description' => 'Category ID (required)'),
830 'duration' => array('type' => 'integer', 'description' => 'Duration in seconds (required). Example: 3600 for 1 hour'),
831 'price' => array('type' => 'number', 'description' => 'Service price (required)'),
832 'providers' => array(
833 'type' => 'array',
834 'items' => array('type' => 'integer'),
835 'description' => 'Array of provider IDs (required). Use amelia/list-employees to find IDs.',
836 ),
837 'show' => array('type' => 'boolean', 'description' => 'Whether to show the service on the website. Default: true'),
838 'color' => array('type' => 'string', 'description' => 'Service color as a hex value (e.g. "#1788FB"). Default: #1788FB'),
839 ),
840 'required' => array('name', 'categoryId', 'duration', 'price', 'providers'),
841 'additionalProperties' => false,
842 ),
843 'output_schema' => array(
844 'type' => 'object',
845 'properties' => array(
846 'service' => array(
847 'type' => 'object',
848 'description' => 'The newly created service',
849 'properties' => array(
850 'id' => array('type' => 'integer', 'description' => 'Assigned service ID'),
851 'name' => array('type' => 'string'),
852 'color' => array('type' => 'string'),
853 'price' => array('type' => 'number'),
854 'duration' => array('type' => 'integer', 'description' => 'Duration in seconds'),
855 'minCapacity' => array('type' => 'integer'),
856 'maxCapacity' => array('type' => 'integer'),
857 'categoryId' => array('type' => 'integer'),
858 'status' => array('type' => 'string'),
859 'show' => array('type' => 'boolean'),
860 ),
861 ),
862 ),
863 ),
864 'execute_callback' => function ($input) {
865 return AmeliaAbilitiesRegistrar::invokeApplication(
866 AddServiceController::class,
867 array(
868 'name' => sanitize_text_field($input['name']),
869 'categoryId' => (int) $input['categoryId'],
870 'duration' => (int) $input['duration'],
871 'price' => (float) $input['price'],
872 'minCapacity' => isset($input['minCapacity']) ? (int) $input['minCapacity'] : 1,
873 'maxCapacity' => isset($input['maxCapacity']) ? (int) $input['maxCapacity'] : 1,
874 'providers' => array_map('intval', (array) $input['providers']),
875 'color' => !empty($input['color']) ? sanitize_text_field($input['color']) : '#1788FB',
876 'status' => 'visible',
877 'show' => isset($input['show']) ? (bool) $input['show'] : true,
878 'description' => '',
879 'depositPayment' => 'disabled',
880 'recurringCycle' => 'disabled',
881 'recurringSub' => 'future',
882 'recurringPayment' => 0,
883 'deposit' => 0,
884 )
885 );
886 },
887 // note: 'status' is in AddServiceController::allowedFields so it passes through.
888 'permission_callback' => function () {
889 return current_user_can('amelia_write_services');
890 },
891 'meta' => array(
892 'annotations' => array(
893 'readonly' => false,
894 'destructive' => true,
895 'idempotent' => false,
896 ),
897 'show_in_rest' => true,
898 'mcp' => array('public' => true),
899 ),
900 ));
901 }
902
903 protected static function registerAddCustomerAbility(): void
904 {
905 wp_register_ability('amelia/add-customer', array(
906 'label' => __('Add Customer', 'wpamelia'),
907 'description' => __(
908 'Use when the user wants to register, add, or create a new customer/client.' .
909 ' ALWAYS confirm first name, last name, and email with the user before calling this ability.',
910 'wpamelia'
911 ),
912 'category' => 'amelia-write',
913 'input_schema' => array(
914 'type' => 'object',
915 'properties' => array(
916 'firstName' => array(
917 'type' => 'string',
918 'description' => 'Customer first name (required). Confirm with the user before submitting.',
919 ),
920 'lastName' => array(
921 'type' => 'string',
922 'description' => 'Customer last name (required). Confirm with the user before submitting.',
923 ),
924 'email' => array(
925 'type' => 'string',
926 'format' => 'email',
927 'description' => 'Customer email address (required). Confirm with the user before submitting.',
928 ),
929 'phone' => array('type' => 'string', 'description' => 'Customer phone number (optional)'),
930 ),
931 'required' => array('firstName', 'lastName', 'email'),
932 'additionalProperties' => false,
933 ),
934 'output_schema' => array(
935 'type' => 'object',
936 'properties' => array(
937 'user' => array(
938 'type' => 'object',
939 'description' => 'The newly created customer',
940 'properties' => array(
941 'id' => array('type' => 'integer', 'description' => 'Assigned customer ID'),
942 'firstName' => array('type' => 'string'),
943 'lastName' => array('type' => 'string'),
944 'email' => array('type' => 'string'),
945 'phone' => array('type' => array('string', 'null')),
946 'type' => array('type' => 'string', 'description' => 'Always "customer"'),
947 'status' => array('type' => 'string'),
948 ),
949 ),
950 ),
951 ),
952 'execute_callback' => function ($input) {
953 $body = array(
954 'firstName' => sanitize_text_field($input['firstName']),
955 'lastName' => sanitize_text_field($input['lastName']),
956 'email' => sanitize_email($input['email']),
957 'type' => 'customer',
958 'status' => 'visible',
959 );
960
961 if (!empty($input['phone'])) {
962 $body['phone'] = sanitize_text_field($input['phone']);
963 }
964
965 return AmeliaAbilitiesRegistrar::invokeApplication(
966 AddCustomerController::class,
967 $body
968 );
969 },
970 'permission_callback' => function () {
971 return current_user_can('amelia_write_customers');
972 },
973 'meta' => array(
974 'annotations' => array(
975 'readonly' => false,
976 'destructive' => true,
977 'idempotent' => false,
978 ),
979 'show_in_rest' => true,
980 'mcp' => array('public' => true),
981 ),
982 ));
983 }
984
985 protected static function registerCreateAppointmentAbility(): void
986 {
987 wp_register_ability('amelia/create-appointment', array(
988 'label' => __('Create Appointment', 'wpamelia'),
989 'description' => __(
990 'Use when the user wants to book, schedule, or reserve an appointment for a customer.' .
991 ' ALWAYS confirm service, employee, customer, and date/time with the user before calling.' .
992 ' Use amelia/list-services, amelia/list-employees, amelia/list-customers, and amelia/check-availability first.',
993 'wpamelia'
994 ),
995 'category' => 'amelia-write',
996 'input_schema' => array(
997 'type' => 'object',
998 'properties' => array(
999 'serviceId' => array(
1000 'type' => 'integer',
1001 'description' => 'Service ID. Use amelia/list-services to find IDs. Confirm with the user before submitting.',
1002 ),
1003 'providerId' => array(
1004 'type' => 'integer',
1005 'description' => 'Provider/employee ID. Use amelia/list-employees to find IDs. Confirm with the user before submitting.',
1006 ),
1007 'customerId' => array(
1008 'type' => 'integer',
1009 'description' => 'Customer ID. Use amelia/list-customers to find IDs. Confirm with the user before submitting.',
1010 ),
1011 'bookingStart' => array(
1012 'type' => 'string',
1013 'pattern' => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
1014 'description' => 'Appointment start date and time in YYYY-MM-DD HH:mm format' .
1015 ' (e.g. "2025-12-25 14:00"). Confirm with the user before submitting.',
1016 ),
1017 'internalNotes' => array('type' => 'string', 'description' => 'Internal notes for the appointment (optional)'),
1018 ),
1019 'required' => array('serviceId', 'providerId', 'customerId', 'bookingStart'),
1020 'additionalProperties' => false,
1021 ),
1022 'output_schema' => array(
1023 'type' => 'object',
1024 'properties' => array(
1025 'appointment' => array(
1026 'type' => 'object',
1027 'description' => 'The newly created appointment',
1028 'properties' => array(
1029 'id' => array('type' => 'integer', 'description' => 'Appointment ID'),
1030 'serviceId' => array('type' => 'integer'),
1031 'providerId' => array('type' => 'integer'),
1032 'locationId' => array('type' => array('integer', 'null')),
1033 'bookingStart' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
1034 'bookingEnd' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
1035 'status' => array('type' => 'string', 'description' => '"approved", "pending", "canceled", "rejected", "waiting"'),
1036 'internalNotes' => array('type' => 'string'),
1037 'bookings' => array(
1038 'type' => 'array',
1039 'items' => array(
1040 'type' => 'object',
1041 'properties' => array(
1042 'id' => array('type' => 'integer', 'description' => 'CustomerBooking ID'),
1043 'customerId' => array('type' => 'integer'),
1044 'persons' => array('type' => 'integer'),
1045 'status' => array('type' => 'string'),
1046 'price' => array('type' => 'number'),
1047 ),
1048 ),
1049 ),
1050 ),
1051 ),
1052 'recurring' => array(
1053 'type' => 'array',
1054 'items' => array('type' => 'object'),
1055 'description' => 'Additional appointments created for recurring bookings',
1056 ),
1057 'timeSlotUnavailable' => array('type' => 'boolean', 'description' => 'True when the slot was already taken'),
1058 'customerAlreadyBooked' => array('type' => 'boolean', 'description' => 'True when the customer has an existing booking for this slot'),
1059 ),
1060 ),
1061 'execute_callback' => function ($input) {
1062 return AmeliaAbilitiesRegistrar::invokeApplication(
1063 AddAppointmentController::class,
1064 array(
1065 'serviceId' => (int) $input['serviceId'],
1066 'providerId' => (int) $input['providerId'],
1067 'bookingStart' => sanitize_text_field($input['bookingStart']),
1068 'notifyParticipants' => 1,
1069 'internalNotes' => !empty($input['internalNotes']) ? sanitize_textarea_field($input['internalNotes']) : '',
1070 'locationId' => null,
1071 'recurring' => array(),
1072 'bookings' => array(
1073 array(
1074 'customerId' => (int) $input['customerId'],
1075 'persons' => 1,
1076 'status' => 'approved',
1077 ),
1078 ),
1079 )
1080 );
1081 },
1082 'permission_callback' => function () {
1083 return current_user_can('amelia_write_appointments');
1084 },
1085 'meta' => array(
1086 'annotations' => array(
1087 'readonly' => false,
1088 'destructive' => true,
1089 'idempotent' => false,
1090 ),
1091 'show_in_rest' => true,
1092 'mcp' => array('public' => true),
1093 ),
1094 ));
1095 }
1096
1097 protected static function registerCreateEventAbility(): void
1098 {
1099 wp_register_ability('amelia/create-event', array(
1100 'label' => __('Create Event', 'wpamelia'),
1101 'description' => __(
1102 'Use when the user wants to create, add, or set up a new event, class, or group session.' .
1103 ' ALWAYS confirm name, start/end date-time, price, and capacity with the user before calling this ability.',
1104 'wpamelia'
1105 ),
1106 'category' => 'amelia-write',
1107 'input_schema' => array(
1108 'type' => 'object',
1109 'properties' => array(
1110 'name' => array(
1111 'type' => 'string',
1112 'description' => 'The name of the event (required). Confirm with the user before submitting.',
1113 ),
1114 'periodStart' => array(
1115 'type' => 'string',
1116 'description' => 'Event start date/time in YYYY-MM-DD HH:mm format (required). Confirm with the user before submitting.',
1117 ),
1118 'periodEnd' => array(
1119 'type' => 'string',
1120 'description' => 'Event end date/time in YYYY-MM-DD HH:mm format (required). Confirm with the user before submitting.',
1121 ),
1122 'price' => array('type' => 'number', 'description' => 'Price of the event. Default: 0'),
1123 'maxCapacity' => array('type' => 'integer', 'minimum' => 1, 'description' => 'Maximum capacity. Default: 10'),
1124 'color' => array('type' => 'string', 'description' => 'Event color as a hex value (e.g. "#1a84ee"). Default: #1a84ee'),
1125 'show' => array('type' => 'boolean', 'description' => 'Whether to show the event on the website. Default: true'),
1126 'depositPayment' => array(
1127 'type' => 'string',
1128 'enum' => array('disabled', 'fixed', 'percentage'),
1129 'description' => 'Deposit payment type. Default: disabled',
1130 ),
1131 'deposit' => array(
1132 'type' => 'number',
1133 'description' => 'Deposit amount (used when depositPayment is fixed or percentage). Default: 0',
1134 ),
1135 ),
1136 'required' => array('name', 'periodStart', 'periodEnd'),
1137 'additionalProperties' => false,
1138 ),
1139 'output_schema' => array(
1140 'type' => 'object',
1141 'properties' => array(
1142 'events' => array(
1143 'type' => 'array',
1144 'description' => 'The created event(s). Multiple items when a recurring series is created.',
1145 'items' => array(
1146 'type' => 'object',
1147 'properties' => array(
1148 'id' => array('type' => 'integer', 'description' => 'Event ID'),
1149 'name' => array('type' => 'string'),
1150 'price' => array('type' => 'number'),
1151 'maxCapacity' => array('type' => 'integer'),
1152 'color' => array('type' => 'string'),
1153 'show' => array('type' => 'boolean'),
1154 'status' => array('type' => 'string'),
1155 'periods' => array(
1156 'type' => 'array',
1157 'items' => array(
1158 'type' => 'object',
1159 'properties' => array(
1160 'periodStart' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
1161 'periodEnd' => array('type' => 'string', 'description' => 'YYYY-MM-DD HH:mm:ss'),
1162 ),
1163 ),
1164 ),
1165 ),
1166 ),
1167 ),
1168 ),
1169 ),
1170 'execute_callback' => function ($input) {
1171 return AmeliaAbilitiesRegistrar::invokeApplication(
1172 AddEventController::class,
1173 array(
1174 'name' => sanitize_text_field($input['name']),
1175 'price' => isset($input['price']) ? (float) $input['price'] : 0,
1176 'maxCapacity' => isset($input['maxCapacity']) ? (int) $input['maxCapacity'] : 10,
1177 'color' => !empty($input['color']) ? sanitize_text_field($input['color']) : '#1a84ee',
1178 'show' => isset($input['show']) ? (bool) $input['show'] : true,
1179 'depositPayment' => !empty($input['depositPayment']) ? sanitize_text_field($input['depositPayment']) : 'disabled',
1180 'deposit' => isset($input['deposit']) ? (float) $input['deposit'] : 0,
1181 'periods' => array(
1182 array(
1183 'periodStart' => sanitize_text_field($input['periodStart']),
1184 'periodEnd' => sanitize_text_field($input['periodEnd']),
1185 ),
1186 ),
1187 )
1188 );
1189 },
1190 'permission_callback' => function () {
1191 return current_user_can('amelia_write_events');
1192 },
1193 'meta' => array(
1194 'annotations' => array(
1195 'readonly' => false,
1196 'destructive' => true,
1197 'idempotent' => false,
1198 ),
1199 'show_in_rest' => true,
1200 'mcp' => array('public' => true),
1201 ),
1202 ));
1203 }
1204
1205 protected static function registerBookEventAbility(): void
1206 {
1207 wp_register_ability('amelia/book-event', array(
1208 'label' => __('Book Event', 'wpamelia'),
1209 'description' => __(
1210 'Use when the user wants to register or enroll a customer in an event, class, or group session.' .
1211 ' ALWAYS confirm the event and customer with the user before calling.' .
1212 ' Use amelia/list-events for eventId and amelia/list-customers or amelia/add-customer for customerId.',
1213 'wpamelia'
1214 ),
1215 'category' => 'amelia-write',
1216 'input_schema' => array(
1217 'type' => 'object',
1218 'properties' => array(
1219 'eventId' => array(
1220 'type' => 'integer',
1221 'description' => 'Event ID. Use amelia/list-events to find IDs. Confirm with the user before submitting.',
1222 ),
1223 'customerId' => array(
1224 'type' => 'integer',
1225 'description' => 'Customer ID. Use amelia/list-customers to find IDs. Confirm with the user before submitting.',
1226 ),
1227 'persons' => array('type' => 'integer', 'minimum' => 1, 'description' => 'Number of persons attending. Default: 1'),
1228 ),
1229 'required' => array('eventId', 'customerId'),
1230 'additionalProperties' => false,
1231 ),
1232 'output_schema' => array(
1233 'type' => 'object',
1234 'properties' => array(
1235 'type' => array('type' => 'string', 'description' => 'Always "event" for this ability'),
1236 'event' => array(
1237 'type' => 'object',
1238 'description' => 'The event that was booked',
1239 'properties' => array(
1240 'id' => array('type' => 'integer'),
1241 'name' => array('type' => 'string'),
1242 'maxCapacity' => array('type' => 'integer'),
1243 'bookedSpots' => array('type' => 'integer'),
1244 ),
1245 ),
1246 'booking' => array(
1247 'type' => 'object',
1248 'description' => 'The CustomerBooking record created for this customer',
1249 'properties' => array(
1250 'id' => array('type' => 'integer', 'description' => 'CustomerBooking ID'),
1251 'customerId' => array('type' => 'integer'),
1252 'persons' => array('type' => 'integer'),
1253 'status' => array('type' => 'string'),
1254 'price' => array('type' => 'number'),
1255 ),
1256 ),
1257 'customer' => array(
1258 'type' => 'object',
1259 'properties' => array(
1260 'id' => array('type' => 'integer'),
1261 'firstName' => array('type' => 'string'),
1262 'lastName' => array('type' => 'string'),
1263 'email' => array('type' => array('string', 'null')),
1264 ),
1265 ),
1266 'paymentId' => array('type' => array('integer', 'null')),
1267 'customerCabinetUrl' => array('type' => 'string', 'description' => 'Customer self-service URL'),
1268 ),
1269 ),
1270 'execute_callback' => function ($input) {
1271 return AmeliaAbilitiesRegistrar::invokeApplication(
1272 AddBookingController::class,
1273 array(
1274 'type' => 'event',
1275 'eventId' => (int) $input['eventId'],
1276 'notifyParticipants' => 1,
1277 'runInstantPostBookingActions' => true,
1278 'isBackendOrCabinet' => true,
1279 'payment' => array('gateway' => 'onSite'),
1280 'bookings' => array(
1281 array(
1282 'eventId' => (int) $input['eventId'],
1283 'customerId' => (int) $input['customerId'],
1284 'persons' => isset($input['persons']) ? (int) $input['persons'] : 1,
1285 'status' => 'approved',
1286 'customer' => array('id' => (int) $input['customerId']),
1287 ),
1288 ),
1289 )
1290 );
1291 },
1292 'permission_callback' => function () {
1293 return current_user_can('amelia_write_events');
1294 },
1295 'meta' => array(
1296 'annotations' => array(
1297 'readonly' => false,
1298 'destructive' => true,
1299 'idempotent' => false,
1300 ),
1301 'show_in_rest' => true,
1302 'mcp' => array('public' => true),
1303 ),
1304 ));
1305 }
1306
1307 protected static function registerCancelBookingAbility(): void
1308 {
1309 wp_register_ability('amelia/cancel-booking', array(
1310 'label' => __('Cancel Booking', 'wpamelia'),
1311 'description' => __(
1312 'Use when the user wants to cancel, remove, or undo a booking.' .
1313 ' ALWAYS ask the user explicitly to confirm before calling:' .
1314 ' "Are you sure you want to cancel booking ID {bookingId}? This cannot be undone."',
1315 'wpamelia'
1316 ),
1317 'category' => 'amelia-write',
1318 'input_schema' => array(
1319 'type' => 'object',
1320 'properties' => array(
1321 'bookingId' => array(
1322 'type' => 'integer',
1323 'description' => 'The ID of the customer booking to cancel. Confirm this ID with the user before submitting.',
1324 ),
1325 'type' => array(
1326 'type' => 'string',
1327 'enum' => array('appointment', 'event'),
1328 'description' => 'Type of the booking to cancel: "appointment" or "event". Default: "appointment".',
1329 ),
1330 ),
1331 'required' => array('bookingId'),
1332 'additionalProperties' => false,
1333 ),
1334 'output_schema' => array(
1335 'type' => 'object',
1336 'properties' => array(
1337 'type' => array('type' => 'string', 'description' => '"appointment" or "event"'),
1338 'status' => array('type' => 'string', 'description' => 'The new booking status, e.g. "canceled"'),
1339 'message' => array('type' => 'string', 'description' => 'Human-readable confirmation message'),
1340 'appointment' => array(
1341 'type' => 'object',
1342 'description' => 'Updated appointment (present when type is "appointment")',
1343 'properties' => array(
1344 'id' => array('type' => 'integer'),
1345 'serviceId' => array('type' => 'integer'),
1346 'providerId' => array('type' => 'integer'),
1347 'bookingStart' => array('type' => 'string'),
1348 'bookingEnd' => array('type' => 'string'),
1349 'status' => array('type' => 'string'),
1350 ),
1351 ),
1352 'event' => array(
1353 'type' => 'object',
1354 'description' => 'Updated event (present when type is "event")',
1355 'properties' => array(
1356 'id' => array('type' => 'integer'),
1357 'name' => array('type' => array('string', 'null')),
1358 'status' => array(
1359 'type' => array('string', 'null'),
1360 'description' => 'Raw event status; see newEventStatus for computed display status',
1361 ),
1362 ),
1363 ),
1364 'newEventStatus' => array(
1365 'type' => 'string',
1366 'description' => 'Computed event display status after cancellation (event bookings only)',
1367 ),
1368 'bookingStatusChanged' => array('type' => 'boolean'),
1369 'booking' => array(
1370 'type' => 'object',
1371 'description' => 'The canceled CustomerBooking record',
1372 'properties' => array(
1373 'id' => array('type' => 'integer'),
1374 'customerId' => array('type' => 'integer'),
1375 'status' => array('type' => 'string'),
1376 ),
1377 ),
1378 'appointmentStatusChanged' => array('type' => 'boolean'),
1379 'updateBookingUnavailable' => array(
1380 'type' => 'boolean',
1381 'description' => 'True when cancellation is not allowed (outside window or capacity constraint)',
1382 ),
1383 ),
1384 ),
1385 'execute_callback' => function ($input) {
1386 $bookingId = (int) $input['bookingId'];
1387 $type = !empty($input['type']) ? sanitize_text_field($input['type']) : 'appointment';
1388
1389 if ($type === Entities::EVENT) {
1390 return AmeliaAbilitiesRegistrar::invokeApplication(
1391 UpdateEventBookingController::class,
1392 array(
1393 'bookings' => array(
1394 array(
1395 'id' => $bookingId,
1396 'status' => 'canceled',
1397 ),
1398 ),
1399 ),
1400 array('id' => $bookingId)
1401 );
1402 }
1403
1404 return AmeliaAbilitiesRegistrar::invokeApplication(
1405 UpdateBookingStatusController::class,
1406 array(
1407 'status' => 'canceled',
1408 'type' => $type,
1409 ),
1410 array('id' => $bookingId)
1411 );
1412 },
1413 'permission_callback' => function () {
1414 return current_user_can('amelia_write_appointments') || current_user_can('amelia_write_events');
1415 },
1416 'meta' => array(
1417 'annotations' => array(
1418 'readonly' => false,
1419 'destructive' => true,
1420 'idempotent' => false,
1421 ),
1422 'show_in_rest' => true,
1423 'mcp' => array('public' => true),
1424 ),
1425 ));
1426 }
1427
1428 /**
1429 * Default page/limit query params for MCP list abilities.
1430 *
1431 * @param array $input
1432 * @return array<string, int>
1433 */
1434 protected static function getMcpListPaginationParams(array $input): array
1435 {
1436 return array(
1437 'page' => !empty($input['page']) ? max(1, (int) $input['page']) : 1,
1438 'limit' => !empty($input['limit'])
1439 ? min(self::MCP_LIST_MAX_LIMIT, max(1, (int) $input['limit']))
1440 : self::MCP_LIST_DEFAULT_LIMIT,
1441 );
1442 }
1443
1444 /**
1445 * Default date range for MCP list abilities: today through N days ahead (site timezone).
1446 *
1447 * @return array{0: string, 1: string}
1448 */
1449 protected static function getDefaultMcpDateRange(): array
1450 {
1451 $start = current_time('Y-m-d');
1452 $end = wp_date(
1453 'Y-m-d',
1454 strtotime('+' . self::MCP_DEFAULT_DATE_RANGE_DAYS . ' days', current_time('timestamp'))
1455 );
1456
1457 return array($start, $end);
1458 }
1459
1460 protected static function canListCustomers(): bool
1461 {
1462 $container = static::getContainer();
1463 $currentUser = $container->get('logged.in.user');
1464
1465 return $container->getPermissionsService()->currentUserCanRead(Entities::CUSTOMERS)
1466 || ($currentUser && $currentUser->getType() === AbstractUser::USER_ROLE_PROVIDER);
1467 }
1468
1469 protected static function canListServices(): bool
1470 {
1471 $container = static::getContainer();
1472 $currentUser = $container->get('logged.in.user');
1473
1474 return $container->getPermissionsService()->currentUserCanRead(Entities::SERVICES)
1475 || ($currentUser && $currentUser->getType() === AbstractUser::USER_ROLE_PROVIDER);
1476 }
1477 }
1478