PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / trunk
Booking for Appointments and Events Calendar – Amelia vtrunk
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 / Integrations / IvyForms / IvyFormsService.php
ameliabooking / src / Infrastructure / WP / Integrations / IvyForms Last commit date
IvyFormsService.php 2 weeks ago
IvyFormsService.php
519 lines
1 <?php
2
3 namespace AmeliaBooking\Infrastructure\WP\Integrations\IvyForms;
4
5 use AmeliaBooking\Domain\Common\Exceptions\InvalidArgumentException;
6 use IvyForms\Controllers\Entry\GetEntryController;
7 use IvyForms\Controllers\Form\FormSubmissionController;
8 use IvyForms\Controllers\Form\ValidateBeforeSubmitController;
9 use IvyForms\Plugin\Plugin;
10 use IvyForms\Services\API\IvyFormsAPI;
11 use WP_REST_Request;
12 use WP_REST_Response;
13
14 /**
15 * Class IvyFormsService
16 *
17 * @package AmeliaBooking\Infrastructure\WP\Integrations\IvyForms
18 */
19 class IvyFormsService
20 {
21 private const IVYFORMS_PLUGIN_FILE = 'ivyforms/ivyforms.php';
22
23 /**
24 * Shortcode handler
25 *
26 * @param string $id
27 * @return string
28 */
29 public static function shortcode(string $id): string
30 {
31 $form = self::getForm(intval($id));
32
33 return !empty($form['published']) ? do_shortcode('[ivyforms id=' . esc_attr($form['id']) . ']') : '';
34 }
35
36 /**
37 * Get a single form (same source as IvyForms REST GET /ivyforms/v1/form/{id}), without HTTP.
38 *
39 * @return array<string, mixed>
40 */
41 public static function getForm(int $id): array
42 {
43 if (!self::isIvyFormsApiAvailable() || $id <= 0) {
44 return [];
45 }
46
47 $form = IvyFormsAPI::getForm($id);
48
49 if (is_wp_error($form) || !is_object($form) || !method_exists($form, 'toArray')) {
50 return [];
51 }
52
53 $formArray = $form->toArray();
54
55 return is_array($formArray) ? $formArray : [];
56 }
57
58 /**
59 * Get forms (same source as IvyForms REST GET /ivyforms/v1/form), without HTTP.
60 *
61 * Uses {@see IvyFormsAPI::getForms()} when IvyForms is active; otherwise returns [].
62 *
63 * @return array<int, array<string, mixed>>
64 */
65 public static function getForms(): array
66 {
67 if (!self::isIvyFormsApiAvailable()) {
68 return [];
69 }
70
71 $result = IvyFormsAPI::getForms();
72
73 if (is_wp_error($result) || !is_array($result)) {
74 return [];
75 }
76
77 $forms = [];
78
79 foreach ($result as $form) {
80 if (is_object($form) && method_exists($form, 'getId') && method_exists($form, 'getName')) {
81 $forms[] = [
82 'value' => $form->getId(),
83 'label' => $form->getName() . ' (id: ' . $form->getId() . ')',
84 ];
85 }
86 }
87
88 return $forms;
89 }
90
91 /**
92 * Get form fields (same source as IvyForms REST fields for a form), without HTTP.
93 *
94 * @return array<int, array{id: int, index: int, key: string, type: string, label: string}>
95 */
96 public static function getFormFields(int $formId): array
97 {
98 if (!self::isIvyFormsApiAvailable() || $formId <= 0) {
99 return [];
100 }
101
102 $fields = IvyFormsAPI::getFields($formId);
103
104 if (is_wp_error($fields) || !is_array($fields)) {
105 return [];
106 }
107
108 $eligibleFields = [];
109
110 foreach ($fields as $field) {
111 if (!is_object($field) || !method_exists($field, 'toArray')) {
112 continue;
113 }
114
115 $fieldArray = $field->toArray();
116 $id = isset($fieldArray['id']) ? (int) $fieldArray['id'] : 0;
117
118 if ($id <= 0 || !in_array($fieldArray['type'], ['email', 'text', 'phone'], true)) {
119 continue;
120 }
121
122 $fieldIndex = $fieldArray['fieldIndex'] ?? null;
123
124 if ($fieldIndex === null || $fieldIndex === '' || !is_numeric($fieldIndex) || (int) $fieldIndex < 0) {
125 continue;
126 }
127
128 $parentId = isset($fieldArray['parentId']) && $fieldArray['parentId'] !== '' && $fieldArray['parentId'] !== null
129 ? (int) $fieldArray['parentId']
130 : null;
131
132 $eligibleFields[] = [
133 'id' => $id,
134 'index' => (int) $fieldIndex,
135 'parentId' => $parentId,
136 'fieldArray' => $fieldArray,
137 ];
138 }
139
140 $generatedNameFieldKeys = self::buildGeneratedNameFieldKeys($eligibleFields);
141
142 $result = [];
143
144 foreach ($eligibleFields as $eligibleField) {
145 $fieldArray = $eligibleField['fieldArray'];
146 $key = '';
147
148 if ($eligibleField['parentId'] !== null) {
149 $nameFieldType = $fieldArray['nameFieldType'] ?? '';
150 $key = is_string($nameFieldType) && $nameFieldType !== ''
151 ? $nameFieldType
152 : ($generatedNameFieldKeys[$eligibleField['id']] ?? '');
153 }
154
155 $result[] = [
156 'id' => $eligibleField['id'],
157 'index' => $eligibleField['index'],
158 'key' => $key,
159 'type' => $eligibleField['parentId'] !== null ? 'name' : $fieldArray['type'],
160 'label' => (string) ($fieldArray['label'] ?? ''),
161 ];
162 }
163
164 return $result;
165 }
166
167 /**
168 * Assign nameField1, nameField2, ... to name subfields missing a stored nameFieldType.
169 * Children are grouped by parentId and ordered by field ID.
170 *
171 * @param array<int, array{id: int, parentId: int|null}> $eligibleFields
172 *
173 * @return array<int, string>
174 */
175 private static function buildGeneratedNameFieldKeys(array $eligibleFields): array
176 {
177 $childrenByParent = [];
178
179 foreach ($eligibleFields as $eligibleField) {
180 if ($eligibleField['parentId'] === null) {
181 continue;
182 }
183
184 $childrenByParent[$eligibleField['parentId']][] = $eligibleField['id'];
185 }
186
187 $generatedKeys = [];
188
189 foreach ($childrenByParent as $childIds) {
190 sort($childIds, SORT_NUMERIC);
191
192 foreach ($childIds as $position => $childId) {
193 $generatedKeys[$childId] = 'nameField' . ($position + 1);
194 }
195 }
196
197 return $generatedKeys;
198 }
199
200 /**
201 * Get entry fields
202 *
203 * @return array<int, array{id: int, label: string, value: mixed}>
204 */
205 public static function getEntryFields(int $entryId): array
206 {
207 if (!self::isIvyFormsApiAvailable() || $entryId <= 0) {
208 return [];
209 }
210
211 $plugin = Plugin::getInstance();
212
213 if (!$plugin instanceof Plugin || $plugin->container === null) {
214 return [];
215 }
216
217 try {
218 /** @var GetEntryController $controller */
219 $controller = $plugin->container->get(GetEntryController::class);
220 } catch (\Throwable $exception) {
221 return [];
222 }
223
224 $request = new WP_REST_Request('GET', '/ivyforms/v1/entry/' . $entryId);
225 $request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest'));
226 $request->set_param('id', $entryId);
227
228 $response = $controller($request);
229
230 $entryResult = self::normalizeGetEntryResponse($response);
231
232 if (empty($entryResult['entry']['formId'])) {
233 return [];
234 }
235
236 $entryFields = $entryResult['fields'] ?? null;
237
238 if (!is_array($entryFields)) {
239 return [];
240 }
241
242 $entry = array_column($entryFields, 'fieldValue', 'fieldId');
243
244 $fields = IvyFormsAPI::getFields($entryResult['entry']['formId']);
245
246 if (is_wp_error($fields) || !is_array($fields)) {
247 return [];
248 }
249
250 $result = [];
251
252 foreach ($fields as $field) {
253 if (!is_object($field) || !method_exists($field, 'toArray')) {
254 continue;
255 }
256
257 $fieldArray = $field->toArray();
258
259 $fieldId = $fieldArray['id'] ?? null;
260 if ($fieldId === null || !isset($entry[$fieldId])) {
261 continue;
262 }
263
264 $fieldValue = '';
265
266 switch ($field->getType()) {
267 case 'checkbox':
268 case 'multi-select':
269 case 'radio':
270 case 'select':
271 $fieldOptions = IvyFormsAPI::getFieldOptions($field->getId());
272 $fieldValues = [];
273
274 if (!is_wp_error($fieldOptions) && is_array($fieldOptions)) {
275 $rawValue = $entry[$fieldId];
276
277 $entriesValues = is_array($rawValue)
278 ? $rawValue
279 : array_filter(array_map('trim', explode(',', (string) $rawValue)));
280
281 foreach ($entriesValues as $entryValue) {
282 foreach ($fieldOptions as $option) {
283 if (!is_object($option) || !method_exists($option, 'getValue') || !method_exists($option, 'getLabel')) {
284 continue;
285 }
286
287 $optionValue = $option->getValue();
288
289 if ($optionValue == $entryValue) {
290 $fieldValues[] = $option->getLabel();
291 }
292 }
293 }
294 }
295
296 $fieldValue = implode(', ', $fieldValues);
297
298 break;
299 default:
300 $fieldValue = $entry[$fieldId];
301
302 break;
303 }
304
305 $result[] = [
306 'id' => $fieldArray['id'],
307 'label' => $fieldArray['label'],
308 'value' => $fieldValue,
309 ];
310 }
311
312 return $result;
313 }
314
315 /**
316 * Ensure IvyForms is bootstrapped (e.g. Elementor editor AJAX may not load all plugins).
317 */
318 private static function isIvyFormsApiAvailable(): bool
319 {
320 if (!function_exists('is_plugin_active')) {
321 require_once ABSPATH . 'wp-admin/includes/plugin.php';
322 }
323
324 if (!is_plugin_active(self::IVYFORMS_PLUGIN_FILE)) {
325 return false;
326 }
327
328 if (!class_exists(IvyFormsAPI::class)) {
329 $pluginPath = WP_PLUGIN_DIR . '/' . self::IVYFORMS_PLUGIN_FILE;
330
331 if (!is_readable($pluginPath)) {
332 return false;
333 }
334
335 // Optional plugin file; path exists only when IvyForms is installed alongside Amelia.
336 /** @phpstan-ignore includeOnce.fileNotFound */
337 include_once $pluginPath;
338 }
339
340 return class_exists(IvyFormsAPI::class);
341 }
342
343 /**
344 * Validate IvyForms entry via the same handler as REST POST /ivyforms/v1/form/validate_before_submit/.
345 *
346 * Expected shape (from IvyForms public form submit): formId, values, optional postId, pageId and referer.
347 *
348 * @param array<string, mixed> $data
349 *
350 * @throws InvalidArgumentException
351 */
352 public static function validateBeforeSubmit(array $data): void
353 {
354 if (!self::isIvyFormsApiAvailable()) {
355 throw new InvalidArgumentException('IvyForms is not available.');
356 }
357
358 $formId = isset($data['formId']) ? (int) $data['formId'] : 0;
359 $values = $data['values'] ?? null;
360
361 if ($formId <= 0 || !is_array($values)) {
362 throw new InvalidArgumentException('Invalid IvyForms entry data.');
363 }
364
365 $plugin = Plugin::getInstance();
366
367 if (!$plugin instanceof Plugin || $plugin->container === null) {
368 throw new InvalidArgumentException('IvyForms plugin is not initialized.');
369 }
370
371 try {
372 /** @var ValidateBeforeSubmitController $controller */
373 $controller = $plugin->container->get(ValidateBeforeSubmitController::class);
374 } catch (\Throwable $exception) {
375 throw new InvalidArgumentException('IvyForms validate before submit is unavailable.');
376 }
377
378 $payload = [
379 'formId' => $formId,
380 'values' => $values,
381 'nonce' => wp_create_nonce('ivyformsFrontSubmissionNonce_' . $formId),
382 ];
383
384 if (isset($data['postId'])) {
385 $payload['postId'] = (int) $data['postId'];
386 }
387
388 if (!empty($data['pageId']) && is_string($data['pageId'])) {
389 $payload['pageId'] = $data['pageId'];
390 }
391
392 if (!empty($data['referer']) && is_string($data['referer'])) {
393 $payload['referer'] = $data['referer'];
394 }
395
396 $request = new WP_REST_Request('POST', '/ivyforms/v1/form/validate_before_submit/');
397 $request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest'));
398 $request->set_body_params($payload);
399
400 $response = $controller($request);
401 $body = $response->get_data();
402
403 if ($response->get_status() >= 400 || !is_array($body)) {
404 $message = is_array($body)
405 ? (string) ($body['message'] ?? 'IvyForms validation failed.')
406 : 'IvyForms validation failed.';
407
408 throw new InvalidArgumentException($message);
409 }
410
411 $validationData = $body['data']['data'] ?? null;
412
413 if (!is_array($validationData) || empty($validationData['valid'])) {
414 throw new InvalidArgumentException('IvyForms entry validation failed.');
415 }
416 }
417
418 /**
419 * Submit an IvyForms entry via the same handler as REST POST /ivyforms/v1/form/submission/.
420 *
421 * Expected shape (from IvyForms public form submit): formId, values, optional postId and referer.
422 *
423 * @param array<string, mixed> $data
424 *
425 * @return array<string, mixed>
426 */
427 public static function addFormEntry(array $data): array
428 {
429 if (!self::isIvyFormsApiAvailable()) {
430 return [];
431 }
432
433 $formId = isset($data['formId']) ? (int) $data['formId'] : 0;
434 $values = $data['values'] ?? null;
435
436 if ($formId <= 0 || !is_array($values)) {
437 return [];
438 }
439
440 $plugin = Plugin::getInstance();
441
442 if (!$plugin instanceof Plugin || $plugin->container === null) {
443 return [];
444 }
445
446 try {
447 /** @var FormSubmissionController $controller */
448 $controller = $plugin->container->get(FormSubmissionController::class);
449 } catch (\Throwable $exception) {
450 return [];
451 }
452
453 $payload = [
454 'formId' => $formId,
455 'values' => $values,
456 'nonce' => wp_create_nonce('ivyformsFrontSubmissionNonce_' . $formId),
457 ];
458
459 if (isset($data['postId'])) {
460 $payload['postId'] = (int) $data['postId'];
461 }
462
463 if (!empty($data['referer']) && is_string($data['referer'])) {
464 $payload['referer'] = $data['referer'];
465 }
466
467 $request = new WP_REST_Request('POST', '/ivyforms/v1/form/submission/');
468 $request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest'));
469 $request->set_body_params($payload);
470
471 $response = $controller($request);
472
473 return self::normalizeSubmissionResponse($response);
474 }
475
476 /**
477 * @param WP_REST_Response $response
478 *
479 * @return array<string, mixed>
480 */
481 private static function normalizeSubmissionResponse(WP_REST_Response $response): array
482 {
483 $body = $response->get_data();
484
485 if (!is_array($body)) {
486 return [];
487 }
488
489 if ($response->get_status() >= 400) {
490 return [
491 'success' => false,
492 'status' => $response->get_status(),
493 'message' => $body['message'] ?? '',
494 'data' => $body['data'] ?? [],
495 ];
496 }
497
498 return $body;
499 }
500
501 /**
502 * @param WP_REST_Response $response
503 *
504 * @return array{entry?: array<string, mixed>, fields?: array<int, mixed>}
505 */
506 private static function normalizeGetEntryResponse(WP_REST_Response $response): array
507 {
508 $body = $response->get_data();
509
510 if (!is_array($body) || $response->get_status() >= 400) {
511 return [];
512 }
513
514 $data = $body['data'] ?? null;
515
516 return is_array($data) ? $data : [];
517 }
518 }
519