PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 All 36 releases
vikbooking / admin / helpers / src / dooraccess / factory.php

factory.php in VikBooking Hotel Booking Engine & PMS trunk, at admin/helpers/src/dooraccess/factory.php

1,856 lines 77.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2025 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * Door Access Factory implementation.
16 *
17 * @since 1.18.4 (J) - 1.8.4 (WP)
18 */
19 final class VBODooraccessFactory
20 {
21 /**
22 * The singleton class instance.
23 *
24 * @var VBODooraccessFactory
25 */
26 protected static $instance = null;
27
28 /**
29 * List of door access integration objects loaded.
30 *
31 * @var VBODooraccessIntegrationAware[]
32 */
33 protected array $integrations = [];
34
35 /**
36 * Class constructor is protected.
37 *
38 * @see getInstance()
39 */
40 protected function __construct()
41 {
42 // load all the available door access integrations
43 $this->loadIntegrations();
44 }
45
46 /**
47 * Access the factory object instance.
48 *
49 * @return self A new or the existing class instance.
50 */
51 public static function getInstance()
52 {
53 if (is_null(static::$instance)) {
54 static::$instance = new static;
55 }
56
57 return static::$instance;
58 }
59
60 /**
61 * Attempts to return the requested integration provider by alias.
62 *
63 * @param string $providerAlias The integration provider alias string.
64 *
65 * @return ?VBODooraccessIntegrationAware
66 */
67 public function getIntegrationProvider(string $providerAlias)
68 {
69 if (!$providerAlias) {
70 return null;
71 }
72
73 foreach ($this->integrations as $integration) {
74 if ($integration->getAlias() === $providerAlias) {
75 // always return a cloned instance of the integration object
76 return clone $integration;
77 }
78 }
79
80 return null;
81 }
82
83 /**
84 * Returns the loaded integration providers.
85 *
86 * @param bool $assoc True to obtain an associative alias-name list,
87 * full objects list otherwise.
88 *
89 * @return array
90 */
91 public function getIntegrationProviders(bool $assoc = false)
92 {
93 if (!$assoc) {
94 return $this->integrations;
95 }
96
97 $providers = [];
98
99 foreach ($this->integrations as $integration) {
100 $providers[$integration->getAlias()] = $integration->getName();
101 }
102
103 return $providers;
104 }
105
106 /**
107 * Loads a list of integration records for the given provider.
108 *
109 * @param string $providerAlias The integration provider alias string.
110 * @param ?int $profileId Optional integration record (profile) ID.
111 *
112 * @return array List of associative arrays, empty array otherwise.
113 */
114 public function loadIntegrationRecords(string $providerAlias, ?int $profileId = null)
115 {
116 $dbo = JFactory::getDbo();
117
118 $dbo->setQuery(
119 $dbo->getQuery(true)
120 ->select('*')
121 ->from($dbo->qn('#__vikbooking_door_access_integrations'))
122 ->where($dbo->qn('provider_alias') . ' = ' . $dbo->q($providerAlias))
123 );
124
125 $records = array_map(function($record) {
126 // return the decoded columns
127 return $this->decodeIntegrationRecord($record);
128 }, $dbo->loadAssocList());
129
130 if ($profileId) {
131 // sort profile records by the given profile ID
132 usort($records, function($a, $b) use ($profileId) {
133 if ($a['id'] == $profileId) {
134 return -1;
135 }
136 if ($b['id'] == $profileId) {
137 return 1;
138 }
139
140 return $a['id'] <=> $b['id'];
141 });
142 }
143
144 return $records;
145 }
146
147 /**
148 * Loads the requested provider integration record ID.
149 *
150 * @param int $profileId Provider integration record (profile) ID.
151 *
152 * @return array Associative record found or empty array.
153 */
154 public function loadIntegrationRecord(int $profileId)
155 {
156 $dbo = JFactory::getDbo();
157
158 $dbo->setQuery(
159 $dbo->getQuery(true)
160 ->select('*')
161 ->from($dbo->qn('#__vikbooking_door_access_integrations'))
162 ->where($dbo->qn('id') . ' = ' . $profileId)
163 );
164
165 $record = $dbo->loadAssoc();
166
167 if (!$record) {
168 return [];
169 }
170
171 // return the decoded columns
172 return $this->decodeIntegrationRecord($record);
173 }
174
175 /**
176 * Loads the integration records capable of generating door-access passcodes.
177 *
178 * @param string|array $generationType The type(s) of generating records.
179 *
180 * @return array List of eligible integration records.
181 *
182 * @throws InvalidArgumentException
183 *
184 * @since 1.18.6 (J) - 1.8.6 (WP) argument $generationType is now of type string|array.
185 */
186 public function loadGeneratingIntegrations($generationType)
187 {
188 $dbo = JFactory::getDbo();
189
190 if (is_string($generationType)) {
191 // always expect an array
192 $generationType = [$generationType];
193 }
194
195 if (!is_array($generationType) || !$generationType) {
196 throw new InvalidArgumentException('Argument $generationType must be either a string or an array of strings.', 500);
197 }
198
199 // quote all generation type values
200 $generationTypes = array_map([$dbo, 'q'], $generationType);
201
202 // fetch eligible provider integrations
203 $dbo->setQuery(
204 $dbo->getQuery(true)
205 ->select('*')
206 ->from($dbo->qn('#__vikbooking_door_access_integrations'))
207 ->where($dbo->qn('gentype') . (count($generationTypes) > 1 ? ' IN (' . implode(', ', $generationTypes) . ')' : ' = ' . $generationTypes[0]))
208 );
209
210 return array_map(function($record) {
211 // return the decoded columns
212 return $this->decodeIntegrationRecord($record);
213 }, $dbo->loadAssocList());
214 }
215
216 /**
217 * Loads the active (properly configured) integration records.
218 *
219 * @return VBODooraccessIntegrationAware[] List of active integration objects.
220 */
221 public function loadActiveIntegrations()
222 {
223 $dbo = JFactory::getDbo();
224
225 $dbo->setQuery(
226 $dbo->getQuery(true)
227 ->select('*')
228 ->from($dbo->qn('#__vikbooking_door_access_integrations'))
229 );
230
231 $records = array_map(function($record) {
232 // return the decoded columns
233 return $this->decodeIntegrationRecord($record);
234 }, $dbo->loadAssocList());
235
236 $activeIntegrations = [];
237
238 // scan all integration records to make sure some devices were configured
239 foreach ($records as $record) {
240 // get the integration provider
241 $integration = $this->getIntegrationProvider($record['provider_alias']);
242 if (!$integration) {
243 // unknown integration provider
244 continue;
245 }
246
247 // inject profile record within the integration provider
248 $integration->setProfileRecord($record);
249
250 // ensure the integration counts some active devices
251 if ($integration->getDevices()) {
252 // push value integration
253 $activeIntegrations[] = $integration;
254 }
255 }
256
257 return $activeIntegrations;
258 }
259
260 /**
261 * Saves or updates a provider integration record.
262 *
263 * @param VBODooraccessIntegrationAware $integration The provider integration object.
264 * @param array $options Associative list of saving options.
265 *
266 * @return void
267 *
268 * @throws Exception
269 */
270 public function saveIntegrationRecord(VBODooraccessIntegrationAware $integration, array $options)
271 {
272 $dbo = JFactory::getDbo();
273
274 // check if we have an existing record
275 $record = $integration->getProfileRecord();
276
277 if (!empty($options['id_profile'])) {
278 // make sure we are updating an existing record
279 $record = $this->loadIntegrationRecord((int) $options['id_profile']);
280
281 if (!$record) {
282 throw new Exception('Invalid integration record ID.', 404);
283 }
284
285 // inject the integration record found
286 $integration->setProfileRecord($record);
287 }
288
289 if (empty($options['profile_name'])) {
290 // ensure we have a valid profile name
291 $options['profile_name'] = $integration->getProfileName() ?: date('Y-m-d H:i:s');
292 }
293
294 // prepare database record
295 $dbRecord = new stdClass;
296
297 if ($record) {
298 // existing record will be updated
299 $dbRecord->id = $integration->getProfileID();
300 }
301
302 // set provider alias
303 $dbRecord->provider_alias = $integration->getAlias();
304
305 // set record name
306 $dbRecord->name = $options['profile_name'];
307
308 if (isset($options['gentype'])) {
309 // set record generation type
310 $dbRecord->gentype = (string) $options['gentype'];
311 }
312
313 if (isset($options['genperiod'])) {
314 // set record generation period
315 $dbRecord->genperiod = (string) $options['genperiod'];
316 }
317
318 if (is_array($options['settings'] ?? null)) {
319 if (!($options['overwrite_settings'] ?? null)) {
320 // merge existing settings with the new ones to allow
321 // custom HTTP Transporter hidden settings to be kept
322 $options['settings'] = array_merge($integration->getSettings(), $options['settings']);
323 }
324 // set record settings
325 $dbRecord->settings = json_encode($options['settings']);
326 }
327
328 if (is_array($options['devices'] ?? null)) {
329 // set record devices map
330 $dbRecord->devices = serialize($options['devices']);
331 }
332
333 if (is_array($options['data'] ?? null)) {
334 // set record data
335 $dbRecord->data = json_encode($options['data']);
336 }
337
338 if (!empty($dbRecord->id)) {
339 // update database record
340 if (!$dbo->updateObject('#__vikbooking_door_access_integrations', $dbRecord, 'id')) {
341 throw new Exception('Could not update integration record.', 500);
342 }
343
344 // process completed
345 return;
346 }
347
348 // create new record
349 $dbo->insertObject('#__vikbooking_door_access_integrations', $dbRecord, 'id');
350 if (empty($dbRecord->id)) {
351 throw new Exception('Could not save integration record.', 500);
352 }
353
354 // properly load the newly created record
355 $record = $this->loadIntegrationRecord((int) $dbRecord->id);
356
357 if (!$record) {
358 throw new Exception('Did not save integration record.', 500);
359 }
360
361 // inject the integration record created to complete the process
362 $integration->setProfileRecord($record);
363 }
364
365 /**
366 * Deletes a provider integration record.
367 *
368 * @param VBODooraccessIntegrationAware $integration The provider integration object.
369 *
370 * @return true
371 *
372 * @throws Exception
373 */
374 public function deleteIntegrationRecord(VBODooraccessIntegrationAware $integration)
375 {
376 $dbo = JFactory::getDbo();
377
378 if (!$integration->hasProfileRecord() || !$integration->getProfileID()) {
379 throw new Exception('Missing integration profile record for deletion.', 404);
380 }
381
382 // delete the current profile record from the database
383 $dbo->setQuery(
384 $dbo->getQuery(true)
385 ->delete($dbo->qn('#__vikbooking_door_access_integrations'))
386 ->where($dbo->qn('id') . ' = ' . $integration->getProfileID())
387 ->where($dbo->qn('provider_alias') . ' = ' . $dbo->q($integration->getProfileProvider()))
388 );
389 $dbo->execute();
390
391 if (!$dbo->getAffectedRows()) {
392 throw new Exception('Could not delete the requested integration profile record.', 500);
393 }
394
395 return true;
396 }
397
398 /**
399 * Fetches and updates the provider remote devices within the active profile record.
400 *
401 * @param VBODooraccessIntegrationAware $integration The provider integration object.
402 *
403 * @return int
404 *
405 * @throws Exception
406 */
407 public function updateProviderDevices(VBODooraccessIntegrationAware $integration)
408 {
409 if (!$integration->hasProfileRecord()) {
410 throw new Exception('Missing integration profile record', 500);
411 }
412
413 // get the previous record devices
414 $previousDevices = $integration->getDevices();
415
416 // fetch the provider remote devices and have them decorated
417 $newDevices = $integration->fetchDevices();
418
419 if ($previousDevices && $newDevices) {
420 // restore the previous connected listings per device identifier, if any
421 foreach ($previousDevices as $previousDevice) {
422 if (!$previousDevice->getConnectedListings() || !$previousDevice->isComplete()) {
423 // this old device had no connected listings
424 continue;
425 }
426
427 // find the corresponding device identifier in the new list
428 foreach ($newDevices as $newDevice) {
429 if ($newDevice->getID() === $previousDevice->getID()) {
430 // device found, set the previously connected listings and sub-units
431 $newDevice->setConnectedListings($previousDevice->getConnectedListings());
432 $newDevice->setConnectedSubunits($previousDevice->getConnectedSubunits());
433 break;
434 }
435 }
436 }
437 }
438
439 // update integration record
440 $this->saveIntegrationRecord($integration, ['devices' => $newDevices]);
441
442 return count($newDevices);
443 }
444
445 /**
446 * Returns a list of special tags for contents parsing. The list will
447 * include one special tag for every configured provider integration record.
448 *
449 * @return array List of special tag strings.
450 */
451 public function getInstalledSpecialTags()
452 {
453 $specialTags = [];
454
455 foreach ($this->loadActiveIntegrations() as $integration) {
456 if ($tag = $integration->getProfileSpecialTag()) {
457 // push special tag string for this integration profile
458 $specialTags[] = $tag;
459 }
460 }
461
462 return $specialTags;
463 }
464
465 /**
466 * Parses all tokens in the given template string and replaces them with the proper
467 * door access passcode(s) that was previously generated for the given booking registry.
468 *
469 * @param VBOBookingRegistry $registry The booking (and rooms booked) registry.
470 * @param string &$tmpl The template string to parse and manipulate.
471 *
472 * @return int|false False if no tokens were found, or number of DAC tokens.
473 */
474 public function parseTokens(VBOBookingRegistry $registry, string &$tmpl)
475 {
476 // parse all special tags related to the door-access framework
477 preg_match_all('/\{door_access\:\s?p([0-9]+)\_[a-z0-9\-\_]+\}/U', $tmpl, $matches);
478
479 // count tags found
480 $tagsCount = count((array) ($matches[0] ?? []));
481 if (!$tagsCount) {
482 // no special tags to parse
483 return false;
484 }
485
486 // build profile IDs-tags associative list
487 $profileIdTags = [];
488 foreach ($matches[0] as $tag) {
489 if (preg_match('/^\{door_access\:\s?p([0-9]+)\_[a-z0-9\-\_]+\}$/', $tag, $matchProfile)) {
490 $profileIdTags[$matchProfile[1]] = $matchProfile[0];
491 }
492 }
493
494 // scan all profile-tags list
495 foreach ($profileIdTags as $profileId => $profileTag) {
496 // load the integration profile record by ID
497 $record = $this->loadIntegrationRecord((int) $profileId);
498
499 // get the integration provider
500 $integration = $this->getIntegrationProvider($record['provider_alias'] ?? '');
501
502 if (!$record || !$integration) {
503 // profile integration record not found
504 $tagsCount--;
505
506 // replace tag within the template string
507 $tmpl = str_replace($profileTag, '', $tmpl);
508
509 // go to the next profile-tag
510 continue;
511 }
512
513 // inject profile record within the integration provider
514 $integration->setProfileRecord($record);
515
516 // find the passcode data that were previously created for this booking by this integration
517 $previousPasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
518 ->getEventsWithData(['ND', 'MD'], function($data) use ($integration) {
519 // cast history data payload to an array
520 $data = (array) $data;
521
522 if (empty($data['provider']) || $data['provider'] != $integration->getProfileProvider()) {
523 // integration provider alias mismatch
524 return false;
525 }
526
527 if (empty($data['profile']) || $data['profile'] != $integration->getProfileID()) {
528 // integration profile ID mismatch
529 return false;
530 }
531
532 if (empty($data['device']) || !$integration->deviceExists((string) $data['device'])) {
533 // unknown device
534 return false;
535 }
536
537 // ensure the history event contains the passcode or its generation properties
538 return !empty($data['passcode']) || !empty($data['props']);
539 });
540
541 if (!$previousPasscodes) {
542 // nothing to set
543 $tagsCount--;
544
545 // replace tag within the template string
546 $tmpl = str_replace($profileTag, '', $tmpl);
547
548 // go to the next profile-tag
549 continue;
550 }
551
552 // get list of booked listing ids and subunits
553 $bookedListingSubunits = $registry->getBookedListingSubunits();
554
555 // count the number of expected passcodes that were generated for this booking
556 $expectedPasscodes = 0;
557
558 // iterate all provider integration devices
559 foreach ($integration->getDevices() as $device) {
560 // count, if any, how many listings (with subunits) are compatible with the current device
561 $expectedPasscodes += $device->countMatchingListingUnits($bookedListingSubunits);
562 }
563
564 if (!$expectedPasscodes) {
565 // the current integration device settings do not support passcodes
566 $tagsCount--;
567
568 // replace tag within the template string
569 $tmpl = str_replace($profileTag, '', $tmpl);
570
571 // go to the next profile-tag
572 continue;
573 }
574
575 // obtain the expected passcodes for this booking
576 $latestBookingPasscodes = [];
577 $passcodesDevicesMap = [];
578
579 // filter out the latest booking events that should contain a valid passcode
580 foreach (array_reverse($previousPasscodes) as $previousData) {
581 // ensure we only have array values
582 $previousData = (array) json_decode(json_encode($previousData), true);
583
584 // get the passcode value generated
585 $passcodeValue = ($previousData['passcode'] ?? '') ?: $integration->getPasscodeFromHistoryResult((array) ($previousData['props'] ?? []));
586
587 if ($passcodeValue) {
588 // push booking passcode
589 $latestBookingPasscodes[] = $passcodeValue;
590
591 // set passcode-device map
592 $passcodesDevicesMap[$passcodeValue] = $integration->getDeviceById($previousData['device'])->getName();
593 }
594
595 if (count($latestBookingPasscodes) === $expectedPasscodes) {
596 // terminate the process to avoid including old passcodes that may have been deleted
597 break;
598 }
599 }
600
601 if (!$latestBookingPasscodes) {
602 // no passcodes were found
603 $tagsCount--;
604
605 // replace tag within the template string
606 $tmpl = str_replace($profileTag, '', $tmpl);
607
608 // go to the next profile-tag
609 continue;
610 }
611
612 // ensure we've only got unique passcodes
613 $latestBookingPasscodes = array_values(array_unique($latestBookingPasscodes));
614
615 if (count($latestBookingPasscodes) > 1) {
616 // map the device name along with the device passcode when multiple passcodes involved
617 $latestBookingPasscodes = array_map(function($passcode) use ($passcodesDevicesMap) {
618 return sprintf('%s: %s', ($passcodesDevicesMap[$passcode] ?? ''), $passcode);
619 }, $latestBookingPasscodes);
620 }
621
622 // we've got one or more passcodes to set as a special tag replacement
623 $tmpl = str_replace($profileTag, implode(', ', $latestBookingPasscodes), $tmpl);
624 }
625
626 return $tagsCount;
627 }
628
629 /**
630 * Attempts to return a list of associative arrays that include the active passcode string
631 * value and the device information based on what was generated for a specific booking.
632 *
633 * @param VBOBookingRegistry $registry The booking registry.
634 *
635 * @return array List of passcode and device name associative
636 * arrays (usually one), or empty array.
637 */
638 public function getBookingDevicePasscodes(VBOBookingRegistry $registry)
639 {
640 // find the passcode data that were previously created for this booking by any integration
641 $previousPasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
642 ->getEventsWithData(['ND', 'MD'], function($data) {
643 // cast history data payload to an array
644 $data = (array) $data;
645
646 // ensure the passcode was generated by/for a valid provider, profile and device
647 return !empty($data['provider']) &&
648 !empty($data['profile']) &&
649 !empty($data['device']) &&
650 (!empty($data['passcode']) || !empty($data['props']));
651 });
652
653 if (!$previousPasscodes) {
654 // nothing was ever created for this booking
655 return [];
656 }
657
658 // get the unique list of booked listing ids and subunits
659 $bookedListingIds = $registry->getBookedListingIds();
660 $bookedListingSubunits = $registry->getBookedListingSubunits();
661
662 // build the pool of devices and passcodes data
663 $devicePasscodesSignatures = [];
664 $devicePasscodesPool = [];
665
666 // iterate over the latest booking events that generated a passcode
667 foreach (array_reverse($previousPasscodes) as $previousData) {
668 // ensure we only have array values
669 $previousData = (array) json_decode(json_encode($previousData), true);
670
671 // build passcode signature with provider and profile identifiers
672 $passcodeSignature = sprintf('%s-%d', (string) $previousData['provider'], (int) $previousData['profile']);
673
674 // access the provider integration
675 $integration = $this->getIntegrationProvider((string) $previousData['provider']);
676 if (!$integration) {
677 // unknown provider
678 continue;
679 }
680
681 // inject profile record within the integration provider
682 $integration->setProfileRecord($this->loadIntegrationRecord((int) $previousData['profile']));
683
684 // ensure this device still exists within the integration provider
685 if (!$integration->deviceExists((string) $previousData['device'])) {
686 // unknown device
687 continue;
688 }
689
690 // count the number of expected passcodes that were generated for this booking by the current integration provider
691 $expectedPasscodes = 0;
692 foreach ($integration->getDevices() as $device) {
693 // count, if any, how many listings (with subunits) are compatible with the current device
694 $expectedPasscodes += $device->countMatchingListingUnits($bookedListingSubunits);
695 }
696
697 // ensure we are not getting too many passcodes for this provider, which may have been cancelled
698 if (($devicePasscodesSignatures[$passcodeSignature] ?? 0) >= $expectedPasscodes) {
699 // fetch no more passcodes for this provider and profile
700 continue;
701 }
702
703 // get the device name and listings involved with the reservation
704 $deviceName = '';
705 $deviceListings = [];
706 try {
707 $device = $integration->getDeviceById((string) $previousData['device']);
708 $deviceName = $device->getName();
709 $deviceListings = array_intersect($bookedListingIds, $device->getConnectedListings());
710 $deviceListings = array_map(function($listingId) {
711 return VikBooking::getRoomInfo($listingId, ['name'], true)['name'] ?? $listingId;
712 }, $deviceListings);
713 } catch (Exception $e) {
714 // fallback to device ID
715 $deviceName = (string) $previousData['device'];
716 }
717
718 // get the passcode value generated
719 $passcodeValue = ($previousData['passcode'] ?? '') ?: $integration->getPasscodeFromHistoryResult((array) ($previousData['props'] ?? []));
720 if (!$passcodeValue) {
721 // unexpected situation
722 continue;
723 }
724
725 // push passcode and device information
726 $devicePasscodesPool[] = [
727 'deviceName' => $deviceName,
728 'deviceId' => $previousData['device'],
729 'passcode' => $passcodeValue,
730 'listings' => $deviceListings,
731 ];
732
733 // increase provider-profile counter
734 $devicePasscodesSignatures[$passcodeSignature] = ($devicePasscodesSignatures[$passcodeSignature] ?? 0) + 1;
735 }
736
737 return $devicePasscodesPool;
738 }
739
740 /**
741 * Attempts to handle the command for unlocking one or more devices to which the rooms booked are assigned.
742 * The method will NOT validate the booking stay dates, such controls should be made prior to calling it.
743 *
744 * @param VBOBookingRegistry $registry The booking registry.
745 * @param ?array $options Associative list of request options.
746 *
747 * @return array List of devices unlock results, if any.
748 *
749 * @throws Exception
750 */
751 public function handleBookingDeviceUnlock(VBOBookingRegistry $registry, ?array $options = null)
752 {
753 // get list of booked listing ids and subunits
754 $bookedListingSubunits = $registry->getBookedListingSubunits();
755
756 // list of provider integrations capable of unlocking a device
757 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
758 return $integration->canUnlockDevices();
759 });
760
761 if (!$integrations) {
762 // raise an error
763 throw new Exception('Unable to unlock remote devices.', 501);
764 }
765
766 // build the device unlock results
767 $unlockResults = [];
768
769 // flag to indicate that the requested device was not found
770 $exactDeviceFound = null;
771
772 // iterate all provider integrations
773 foreach ($integrations as $integration) {
774 // iterate all integration devices
775 foreach ($integration->getDevices() as $device) {
776 // get the listing-subunit pairs compatible with the current device
777 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
778
779 if (!$deviceListingUnits) {
780 // the device is not connected to any of the booked listings
781 continue;
782 }
783
784 if (($options['device_id'] ?? null) && trim((string) $options['device_id']) != trim($device->getID())) {
785 // this is not the requested device ID to unlock
786 $exactDeviceFound = false;
787 continue;
788 }
789
790 if (($options['device_name'] ?? null)) {
791 $seek_name = trim((string) $options['device_name']);
792 if (stripos($device->getName(), $seek_name) === false && stripos($seek_name, $device->getName()) === false) {
793 // this is not the requested device name to unlock
794 $exactDeviceFound = false;
795 continue;
796 }
797 }
798
799 try {
800 // unlock the device
801 $result = $integration->handleUnlockDevice($device);
802
803 if (!$result) {
804 // throw an error
805 throw new Exception('The device cannot be unlocked.', 501);
806 }
807
808 // push the successful unlock result
809 $unlockResults[] = [
810 'deviceName' => $device->getName(),
811 'deviceId' => $device->getID(),
812 'unlocked' => true,
813 'message' => (string) $result,
814 ];
815 } catch (Exception $e) {
816 // push the faulty unlock result
817 $unlockResults[] = [
818 'deviceName' => $device->getName(),
819 'deviceId' => $device->getID(),
820 'unlocked' => false,
821 'message' => $e->getMessage() ?: 'Unlocking the device failed.',
822 ];
823 }
824 }
825 }
826
827 if (!$unlockResults) {
828 // raise an error
829 if ($exactDeviceFound === false) {
830 throw new Exception('Could not find the requested device to unlock.', 404);
831 }
832 throw new Exception('None of the booked listings has got a device/door to unlock/open. No compatible devices were found.', 400);
833 }
834
835 return $unlockResults;
836 }
837
838 /**
839 * Takes care of cleaning the expired passcodes to free up memory on the device.
840 *
841 * @param ?array $options Optional associative list of processing options.
842 *
843 * @return int Number of passcodes deleted.
844 *
845 * @since 1.18.7 (J) - 1.8.7 (WP)
846 */
847 public function cleanExpiredPasscodes(?array $options = null)
848 {
849 // count total passcodes deleted
850 $passcodesDeleted = 0;
851
852 // list of configured provider integrations to delete expired passcodes
853 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
854 return $integration->canCleanExpiredPasscodes();
855 });
856
857 if (!$integrations) {
858 // do not proceed
859 return $passcodesDeleted;
860 }
861
862 // calculate timestamp bounds, last week by default
863 $tsBounds = [
864 strtotime('00:00:00', strtotime(($options['date_from'] ?? date('Y-m-d', strtotime('-1 week'))))),
865 strtotime('23:59:59', strtotime(($options['date_to'] ?? date('Y-m-d', strtotime('-1 day')))))
866 ];
867
868 // load the departed reservations in the last week
869 $lastDepartures = $this->loadCheckedOutReservations($tsBounds);
870
871 // map bookings with previously generated passcodes
872 $lastDepartures = array_map(function($booking) {
873 // set booking DAC passcodes
874 $booking['_dac_passcodes_data'] = [];
875
876 // check if any passcode was previously created
877 $previousPasscodes = VikBooking::getBookingHistoryInstance($booking['id'])
878 ->getEventsWithData(['ND', 'MD'], function($data) {
879 // cast history data payload to an array
880 $data = (array) $data;
881
882 // ensure the passcode was generated by/for a valid provider, profile and device
883 return !empty($data['provider']) &&
884 !empty($data['profile']) &&
885 !empty($data['device']) &&
886 (!empty($data['passcode']) || !empty($data['props']));
887 });
888
889 foreach (array_reverse((array) $previousPasscodes) as $previousData) {
890 // ensure we only have array values
891 $previousData = (array) json_decode(json_encode($previousData), true);
892
893 // push booking passcode data
894 $passcodeData = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
895
896 if ($passcodeData) {
897 // push booking passcode data
898 $booking['_dac_passcodes_data'][] = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
899 }
900 }
901
902 // returned the mapped booking array
903 return $booking;
904 }, $lastDepartures);
905
906 // filter out bookings for which no passcodes were ever generated
907 $lastDepartures = array_filter($lastDepartures, function($booking) {
908 // ensure booking passcodes data is set
909 return !empty($booking['_dac_passcodes_data']);
910 });
911
912 if (!$lastDepartures) {
913 // nothing to delete at this time
914 return $passcodesDeleted;
915 }
916
917 // build the readable date bounds
918 $startInfo = getdate($tsBounds[0]);
919 $endInfo = getdate($tsBounds[1]);
920 $targetDtFrom = sprintf('%s %d', VikBooking::sayMonth($startInfo['mon'], true), $startInfo['mday']);
921 $targetDtTo = sprintf('%s %d', VikBooking::sayMonth($endInfo['mon'], true), $endInfo['mday']);
922 if ($startInfo['year'] != $endInfo['year']) {
923 // append short year to both target dates
924 $targetDtFrom .= ' ' . date('y', $tsBounds[0]);
925 $targetDtTo .= ' ' . date('y', $tsBounds[1]);
926 } else {
927 // append full year to target end date
928 $targetDtTo .= ' ' . $endInfo['year'];
929 }
930
931 // iterate departed reservations
932 foreach ($lastDepartures as $booking) {
933 // wrap the booking information into a registry
934 $registry = VBOBookingRegistry::getInstance($booking);
935
936 // get list of booked listing ids and subunits
937 $bookedListingSubunits = $registry->getBookedListingSubunits();
938
939 // scan all the eligible integration records
940 foreach ($integrations as $integration) {
941 // set DAC passcodes data within the booking registry
942 $registry->setDACProperty($integration->getAlias(), 'passcodes_data', $booking['_dac_passcodes_data'] ?? []);
943
944 // start integration counter
945 $integrationDeletion = 0;
946
947 // iterate all provider integration devices
948 foreach ($integration->getDevices() as $device) {
949 // get the listing-subunit pairs compatible with the current device
950 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
951
952 // iterate all listing units connected to the current device, if any
953 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
954 // obtain listing ID and subunit number
955 list($listingId, $subunitId) = $listingSubunitPair;
956
957 // set current room index to identify a multi-room booking context
958 $registry->setCurrentRoomIndex($listingIndex);
959
960 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
961 $registry->setCurrentRoomNumber($subunitId);
962
963 try {
964 // attempt to delete a previously created passcode on this device for the current booking
965 if ($integration->cancelBookingDoorAccess($device, $listingId, $registry)) {
966 // increase global counter
967 $passcodesDeleted++;
968
969 // increase integration counter
970 $integrationDeletion++;
971 }
972 } catch (Exception $e) {
973 // do nothing
974 }
975 }
976 }
977
978 if ($integrationDeletion) {
979 // store an entry within the notifications center for the successful operation
980 VBOFactory::getNotificationCenter()
981 ->store([
982 [
983 'sender' => 'dac',
984 'type' => 'dac.EX.ok',
985 'title' => sprintf('%s', (string) $integration->getProfileName()),
986 'summary' => JText::sprintf('VBO_EXP_PASSCODES_DEL_OK_RES', $integrationDeletion, $targetDtFrom, $targetDtTo),
987 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
988 ],
989 ]);
990 }
991 }
992 }
993
994 return $passcodesDeleted;
995 }
996
997 /**
998 * Watches the daily arrivals to notify on the first access through booking passcodes.
999 *
1000 * @param ?array $options Optional associative list of watching options.
1001 *
1002 * @return int Number of first access found.
1003 *
1004 * @since 1.18.6 (J) - 1.8.6 (WP)
1005 */
1006 public function watchFirstAccess(?array $options = null)
1007 {
1008 // count the first access found
1009 $firstAccessCount = 0;
1010
1011 // list of configured provider integrations to watch first access
1012 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
1013 return $integration->canWatchFirstAccess();
1014 });
1015
1016 if (!$integrations) {
1017 // do not proceed
1018 return $firstAccessCount;
1019 }
1020
1021 // load the arrivals for today
1022 $todayArrivals = $this->loadUpcomingReservations([
1023 strtotime('00:00:00', strtotime(($options['date_from'] ?? date('Y-m-d')))),
1024 strtotime('23:59:59', strtotime(($options['date_to'] ?? date('Y-m-d'))))
1025 ]);
1026
1027 // map today bookings with previously generated passcodes
1028 $todayArrivals = array_map(function($booking) {
1029 // set booking DAC passcodes
1030 $booking['_dac_passcodes_data'] = [];
1031
1032 // check if any passcode was previously created
1033 $previousPasscodes = VikBooking::getBookingHistoryInstance($booking['id'])
1034 ->getEventsWithData(['ND', 'MD'], function($data) {
1035 // cast history data payload to an array
1036 $data = (array) $data;
1037
1038 // ensure the passcode was generated by/for a valid provider, profile and device
1039 return !empty($data['provider']) &&
1040 !empty($data['profile']) &&
1041 !empty($data['device']) &&
1042 (!empty($data['passcode']) || !empty($data['props']));
1043 });
1044
1045 foreach (array_reverse((array) $previousPasscodes) as $previousData) {
1046 // ensure we only have array values
1047 $previousData = (array) json_decode(json_encode($previousData), true);
1048
1049 // push booking passcode data
1050 $passcodeData = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
1051
1052 if ($passcodeData) {
1053 // push booking passcode data
1054 $booking['_dac_passcodes_data'][] = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
1055 }
1056 }
1057
1058 // returned the mapped booking array
1059 return $booking;
1060 }, $todayArrivals);
1061
1062 // filter out today bookings that should not be watched or that were watched already
1063 $todayArrivals = array_filter($todayArrivals, function($booking) {
1064 // ensure booking passcodes data is set and no history events are available for "first access"
1065 return !empty($booking['_dac_passcodes_data']) && !VikBooking::getBookingHistoryInstance($booking['id'])->hasEvent('FA');
1066 });
1067
1068 if (!$todayArrivals) {
1069 // nothing to watch for today at this time
1070 return $firstAccessCount;
1071 }
1072
1073 // iterate bookings arriving today
1074 foreach ($todayArrivals as $booking) {
1075 // wrap the booking information into a registry
1076 $registry = VBOBookingRegistry::getInstance($booking);
1077
1078 // get list of booked listing ids and subunits
1079 $bookedListingSubunits = $registry->getBookedListingSubunits();
1080
1081 // scan all the eligible integration records
1082 foreach ($integrations as $integration) {
1083 // set DAC passcodes data within the booking registry
1084 $registry->setDACProperty($integration->getAlias(), 'passcodes_data', $booking['_dac_passcodes_data'] ?? []);
1085
1086 // iterate all provider integration devices
1087 foreach ($integration->getDevices() as $device) {
1088 // get the listing-subunit pairs compatible with the current device
1089 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1090
1091 // iterate all listing units connected to the current device, if any
1092 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1093 // obtain listing ID and subunit number
1094 list($listingId, $subunitId) = $listingSubunitPair;
1095
1096 // set current room index to identify a multi-room booking context
1097 $registry->setCurrentRoomIndex($listingIndex);
1098
1099 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1100 $registry->setCurrentRoomNumber($subunitId);
1101
1102 try {
1103 // attempt to find the first access on this device for the current booking
1104 $result = $integration->detectFirstAccess($device, $listingId, $registry);
1105
1106 // parse the device capability execution result
1107 if ($result) {
1108 // increase counter
1109 $firstAccessCount++;
1110
1111 // store booking history record
1112 VikBooking::getBookingHistoryInstance($registry->getID())
1113 ->setBookingData($registry->getData(), $registry->getRooms())
1114 ->setExtraData([
1115 'provider' => $integration->getProfileProvider(),
1116 'profile' => $integration->getProfileID(),
1117 'device' => $device->getID(),
1118 ])
1119 ->store('FA', sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result));
1120
1121 // store an entry within the notifications center for the successful operation
1122 VBOFactory::getNotificationCenter()
1123 ->store([
1124 [
1125 'sender' => 'dac',
1126 'type' => 'dac.FA.ok',
1127 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1128 'summary' => sprintf('%s: %s', JText::translate('VBOBOOKHISTORYTFA'), strip_tags((string) $result)),
1129 'idorder' => $registry->getID(),
1130 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1131 ],
1132 ]);
1133
1134 // update booking registration status to "checked-in"
1135 (new VBOModelReservation)->updateRegistration(1, $registry->getID());
1136 }
1137 } catch (Exception $e) {
1138 // do nothing
1139 }
1140 }
1141 }
1142 }
1143 }
1144
1145 return $firstAccessCount;
1146 }
1147
1148 /**
1149 * Handles the upcoming check-ins by triggering the operations involving provider devices.
1150 * This method is constantly executed as a cron-schedule by the CMS platform itself.
1151 * Will invoke only the provider integrations that generate passcodes "before the check-in".
1152 *
1153 * @return bool True if some door-access-control actions were performed, false otherwise.
1154 */
1155 public function handleUpcomingArrivals()
1156 {
1157 // count the door access handling actions performed
1158 $doorAccessActions = 0;
1159
1160 // scan the eligible integration records for generating door-access passcodes at the time of booking
1161 foreach ($this->loadGeneratingIntegrations('checkin') as $record) {
1162 // get the integration provider
1163 $integration = $this->getIntegrationProvider($record['provider_alias']);
1164 if (!$integration) {
1165 // unknown integration provider
1166 continue;
1167 }
1168
1169 // inject profile record within the integration provider
1170 $integration->setProfileRecord($record);
1171
1172 // load the upcoming check-ins within the configured profile period, if any
1173 foreach ($this->loadUpcomingReservations($integration->getNextGenerationPeriodTimestamps()) as $booking) {
1174 // wrap the booking information into a registry
1175 $registry = VBOBookingRegistry::getInstance($booking);
1176
1177 // ensure this booking was never processed before
1178 if ($integration->getBookingAccessProcessed($registry->getID())) {
1179 // skip this booking as it was already processed
1180 continue;
1181 }
1182
1183 // flag the booking as processed
1184 $integration->setBookingAccessProcessed($registry->getID());
1185
1186 // get list of booked listing ids and subunits
1187 $bookedListingSubunits = $registry->getBookedListingSubunits();
1188
1189 // iterate all provider integration devices
1190 foreach ($integration->getDevices() as $device) {
1191 // get the listing-subunit pairs compatible with the current device
1192 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1193
1194 // iterate all listing units connected to the current device, if any
1195 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1196 // obtain listing ID and subunit number
1197 list($listingId, $subunitId) = $listingSubunitPair;
1198
1199 // set current room index to identify a multi-room booking context
1200 $registry->setCurrentRoomIndex($listingIndex);
1201
1202 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1203 $registry->setCurrentRoomNumber($subunitId);
1204
1205 // call door access control on provider record for the current device, listing and booking
1206 try {
1207 // set proper history/notification type first
1208 $historyType = 'ND';
1209
1210 // new booking
1211 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1212
1213 // parse the device capability execution result
1214 if ($result) {
1215 // increase counter
1216 $doorAccessActions++;
1217
1218 // store booking history record
1219 VikBooking::getBookingHistoryInstance($registry->getID())
1220 ->setBookingData($registry->getData(), $registry->getRooms())
1221 ->setExtraData([
1222 'provider' => $integration->getProfileProvider(),
1223 'profile' => $integration->getProfileID(),
1224 'device' => $device->getID(),
1225 'passcode' => $result->getPasscode(),
1226 'props' => $result->getProperties(),
1227 ])
1228 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1229
1230 // store an entry within the notifications center for the successful operation
1231 try {
1232 VBOFactory::getNotificationCenter()
1233 ->store([
1234 [
1235 'sender' => 'dac',
1236 'type' => sprintf('dac.%s.ok', $historyType),
1237 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1238 'summary' => strip_tags((string) $result),
1239 'idorder' => $registry->getID(),
1240 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1241 'label' => $integration->getName(),
1242 'widget' => 'door_access_control',
1243 'widget_options' => [
1244 'provider' => $integration->getProfileProvider(),
1245 'profile' => $integration->getProfileID(),
1246 'device' => $device->getID(),
1247 ],
1248 ],
1249 ]);
1250 } catch (Exception $e) {
1251 // do nothing
1252 }
1253 }
1254 } catch (Exception $e) {
1255 // check if the error exception contains retry data
1256 $retryData = [];
1257 if ($e instanceof VBODooraccessException) {
1258 // obtain the retry information
1259 $retryData = [
1260 'callback' => $e->getRetryCallback(),
1261 'options' => $e->getRetryData(),
1262 ];
1263 }
1264
1265 // store an entry within the notifications center for the failed operation
1266 try {
1267 VBOFactory::getNotificationCenter()
1268 ->store([
1269 [
1270 'sender' => 'dac',
1271 'type' => sprintf('dac.%s.nok', $historyType),
1272 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1273 'summary' => $e->getMessage() ?: 'An error occurred.',
1274 'idorder' => $registry->getID(),
1275 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1276 'label' => JText::translate('VBO_TAKE_ACTION'),
1277 'widget' => 'door_access_control',
1278 'widget_options' => [
1279 'provider' => $integration->getProfileProvider(),
1280 'profile' => $integration->getProfileID(),
1281 'device' => $device->getID(),
1282 'retry_data' => $retryData,
1283 ],
1284 ],
1285 ]);
1286 } catch (Exception $e) {
1287 // do nothing
1288 }
1289 }
1290 }
1291 }
1292 }
1293
1294 // update integration record
1295 $this->saveIntegrationRecord($integration, ['data' => $integration->getData()]);
1296 }
1297
1298 return (bool) $doorAccessActions;
1299 }
1300
1301 /**
1302 * Triggers the operations involving provider devices during a new booking confirmation event.
1303 *
1304 * @param array $booking The booking record.
1305 * @param array $booking_rooms The booking room records.
1306 *
1307 * @return bool
1308 */
1309 public function processBookingConfirmation(array $booking, array $booking_rooms = [])
1310 {
1311 // wrap the booking information into a registry
1312 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1313
1314 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1315 // do nothing when we're not dealing with a real confirmed and accepted reservation
1316 return false;
1317 }
1318
1319 // new booking
1320 return $this->handleBookingEvent('confirmation', $registry);
1321 }
1322
1323 /**
1324 * Triggers the operations involving provider devices during a booking modification event.
1325 *
1326 * @param array $booking The booking record.
1327 * @param array $booking_rooms The booking room records.
1328 * @param array $prev_booking The previous booking record.
1329 *
1330 * @return bool
1331 */
1332 public function processBookingModification(array $booking, array $booking_rooms = [], array $prev_booking = [])
1333 {
1334 // wrap the booking information into a registry
1335 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms, $prev_booking);
1336
1337 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1338 // do nothing when we're not dealing with a real confirmed and accepted reservation
1339 return false;
1340 }
1341
1342 // detect changes from previous to current booking even at room-level (subunits)
1343 if (!$registry->detectAlterations($roomLevel = true)) {
1344 // do nothing when no significant changes were made to the booking
1345 return false;
1346 }
1347
1348 // modified booking
1349 return $this->handleBookingEvent('modification', $registry);
1350 }
1351
1352 /**
1353 * Triggers the operations involving provider devices during a booking cancellation event.
1354 *
1355 * @param array $booking The booking record.
1356 * @param array $booking_rooms The booking room records.
1357 *
1358 * @return bool
1359 */
1360 public function processBookingCancellation(array $booking, array $booking_rooms = [])
1361 {
1362 // wrap the booking information into a registry
1363 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1364
1365 if ($registry->isClosure() || (!$registry->isCancelled() && !$registry->getPrevious())) {
1366 // do nothing when we're not dealing with a real booking cancellation or modification
1367 return false;
1368 }
1369
1370 // cancelled booking
1371 return $this->handleBookingEvent('cancellation', $registry);
1372 }
1373
1374 /**
1375 * Triggers the operations involving provider devices during a pre-checkin completed event.
1376 *
1377 * @param array $booking The booking record.
1378 * @param array $booking_rooms The booking room records.
1379 *
1380 * @return bool
1381 *
1382 * @since 1.18.6 (J) - 1.8.6 (WP)
1383 */
1384 public function processPrecheckinCompleted(array $booking, array $booking_rooms = [])
1385 {
1386 // wrap the booking information into a registry
1387 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1388
1389 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1390 // do nothing when we're not dealing with a real confirmed and accepted reservation
1391 return false;
1392 }
1393
1394 // ensure this is the first time the pre-checkin details are submitted, and not simply updated
1395 $totalPrecheckins = VikBooking::getBookingHistoryInstance($registry->getID())->getEventsWithData(['PC']);
1396 if ($totalPrecheckins && count($totalPrecheckins) > 1) {
1397 // this booking already got the pre-checkin information submitted once
1398 return false;
1399 }
1400
1401 // handle the pre-checkin completed event
1402 return $this->handleBookingEvent('precheckin', $registry);
1403 }
1404
1405 /**
1406 * Spawns the callback by reaching the OAuth2 authorization link (callback URL).
1407 *
1408 * @param ?array $data Optional spawn data to parse.
1409 *
1410 * @return void
1411 *
1412 * @throws Exception
1413 *
1414 * @since 1.18.6 (J) - 1.8.6 (WP)
1415 */
1416 public function spawnOAuthCallback(?array $data = null)
1417 {
1418 $app = JFactory::getApplication();
1419
1420 // gather request or data variables to load the proper integration
1421 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1422 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1423
1424 if (!$provider) {
1425 throw new Exception('Missing DAC integration provider to spawn.', 400);
1426 }
1427
1428 if (!$profileId) {
1429 throw new Exception('Missing DAC integration profile to spawn.', 400);
1430 }
1431
1432 // get the requested integration provider
1433 $integration = $this->getIntegrationProvider($provider);
1434
1435 if (!$integration) {
1436 throw new Exception('Invalid door access control provider identifier.', 404);
1437 }
1438
1439 // load the requested integration profile
1440 $profile = $this->loadIntegrationRecord((int) $profileId);
1441
1442 if (!$profile) {
1443 throw new Exception('Invalid door access control provider profile.', 404);
1444 }
1445
1446 // inject profile record within the integration
1447 $integration->setProfileRecord($profile);
1448
1449 // let the integration spawn the OAuth authorization callback to perform its actions
1450 $integration->spawnOAuthCallback($data);
1451 }
1452
1453 /**
1454 * Spawns the Webhook endpoint URL callback.
1455 *
1456 * @param ?array $data Optional spawn data to parse.
1457 *
1458 * @return void
1459 *
1460 * @throws Exception
1461 *
1462 * @since 1.18.6 (J) - 1.8.6 (WP)
1463 */
1464 public function spawnWebhookCallback(?array $data = null)
1465 {
1466 $app = JFactory::getApplication();
1467
1468 // gather request or data variables to load the proper integration
1469 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1470 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1471
1472 if (!$provider) {
1473 throw new Exception('Missing DAC integration provider to spawn.', 400);
1474 }
1475
1476 if (!$profileId) {
1477 throw new Exception('Missing DAC integration profile to spawn.', 400);
1478 }
1479
1480 // get the requested integration provider
1481 $integration = $this->getIntegrationProvider($provider);
1482
1483 if (!$integration) {
1484 throw new Exception('Invalid door access control provider identifier.', 404);
1485 }
1486
1487 // load the requested integration profile
1488 $profile = $this->loadIntegrationRecord((int) $profileId);
1489
1490 if (!$profile) {
1491 throw new Exception('Invalid door access control provider profile.', 404);
1492 }
1493
1494 // inject profile record within the integration
1495 $integration->setProfileRecord($profile);
1496
1497 // let the integration spawn the Webhook endpoint URL callback to perform its actions
1498 $integration->spawnWebhookCallback($data);
1499 }
1500
1501 /**
1502 * Handles a booking event by triggering the operations involving provider devices.
1503 * Will invoke only the provider integrations that can generate passcodes either
1504 * "at the time of booking" or "upon pre-checkin completed".
1505 *
1506 * @param string $type The booking event type (confirmation, modification, cancellation, precheckin).
1507 * @param VBOBookingRegistry $registry The booking registry containing all room related details.
1508 *
1509 * @return bool True if some door-access-control actions were performed, false otherwise.
1510 *
1511 * @since 1.18.6 (J) - 1.8.6 (WP) added support to pre-checkin event.
1512 */
1513 protected function handleBookingEvent(string $type, VBOBookingRegistry $registry)
1514 {
1515 // list of supported booking event types
1516 $validTypes = [
1517 'confirmation',
1518 'modification',
1519 'cancellation',
1520 'precheckin',
1521 ];
1522
1523 if (!in_array($type, $validTypes)) {
1524 // unsupported booking event
1525 return false;
1526 }
1527
1528 // determine the allowed generation type(s) for the integrations
1529 if ($type === 'confirmation') {
1530 // only integrations that generate passcodes "at the time of booking" should run
1531 $generationTypes = ['booking'];
1532 } elseif ($type === 'precheckin') {
1533 // only integrations that generate passcodes "upon pre-checkin completed" should run
1534 $generationTypes = ['precheckin'];
1535 } else {
1536 // both "at the time of booking" and "upon pre-checkin completed" integrations should run
1537 $generationTypes = ['booking', 'precheckin'];
1538 }
1539
1540 // count the door access handling actions performed
1541 $doorAccessActions = 0;
1542
1543 // get list of booked listing ids and subunits
1544 $bookedListingSubunits = $registry->getBookedListingSubunits();
1545
1546 // check if the booking was only modified at room-level (subunits)
1547 $modifiedRoomLevelOnly = false;
1548 if ($type === 'modification' && !$registry->detectAlterations($roomLevel = false)) {
1549 // turn flag on, because no booking-level changes were detected, only room-level changes (subunits)
1550 $modifiedRoomLevelOnly = true;
1551 }
1552
1553 // scan the eligible integrations for generating passcodes at the time of booking or pre-checkin
1554 foreach ($this->loadGeneratingIntegrations($generationTypes) as $record) {
1555 // get the integration provider
1556 $integration = $this->getIntegrationProvider($record['provider_alias']);
1557 if (!$integration) {
1558 // unknown integration provider
1559 continue;
1560 }
1561
1562 // inject profile record within the integration provider
1563 $integration->setProfileRecord($record);
1564
1565 // ensure a booking modification event will not generate a new passcode too early
1566 if (($record['gentype'] ?? '') === 'precheckin' && in_array($type, ['modification', 'cancellation']) && !$registry->hasPreCheckedIn()) {
1567 // avoid premature actions because the guest has not gone through pre-checkin yet
1568 continue;
1569 }
1570
1571 // iterate all provider integration devices
1572 foreach ($integration->getDevices() as $device) {
1573 if ($modifiedRoomLevelOnly && !$device->getConnectedSubunits()) {
1574 // prevent useless passcode modifications in case of room-level only changes and no subunits mapped
1575 continue;
1576 }
1577
1578 // get the listing-subunit pairs compatible with the current device
1579 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1580
1581 // iterate all listing units connected to the current device, if any
1582 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1583 // obtain listing ID and subunit number
1584 list($listingId, $subunitId) = $listingSubunitPair;
1585
1586 // set current room index to identify a multi-room booking context
1587 $registry->setCurrentRoomIndex($listingIndex);
1588
1589 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1590 $registry->setCurrentRoomNumber($subunitId);
1591
1592 // call door access control on provider record for the current device, listing and booking
1593 try {
1594 if ($type === 'modification') {
1595 // set proper history/notification type first
1596 $historyType = 'MD';
1597 // booking modified
1598 $result = $integration->modifyBookingDoorAccess($device, $listingId, $registry);
1599 } elseif ($type === 'cancellation') {
1600 // set proper history/notification type first
1601 $historyType = 'CD';
1602 // booking cancelled
1603 $result = $integration->cancelBookingDoorAccess($device, $listingId, $registry);
1604 } else {
1605 // new booking or pre-checkin completed event
1606 // set proper history/notification type first
1607 $historyType = 'ND';
1608 // new booking
1609 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1610 }
1611
1612 // parse the device capability execution result
1613 if ($result) {
1614 // increase counter
1615 $doorAccessActions++;
1616
1617 // store booking history record
1618 VikBooking::getBookingHistoryInstance($registry->getID())
1619 ->setBookingData($registry->getData(), $registry->getRooms())
1620 ->setExtraData([
1621 'provider' => $integration->getProfileProvider(),
1622 'profile' => $integration->getProfileID(),
1623 'device' => $device->getID(),
1624 'passcode' => $result->getPasscode(),
1625 'props' => $result->getProperties(),
1626 ])
1627 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1628
1629 // store an entry within the notifications center for the successful operation
1630 try {
1631 VBOFactory::getNotificationCenter()
1632 ->store([
1633 [
1634 'sender' => 'dac',
1635 'type' => sprintf('dac.%s.ok', $historyType),
1636 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1637 'summary' => strip_tags((string) $result),
1638 'idorder' => $registry->getID(),
1639 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1640 'label' => $integration->getName(),
1641 'widget' => 'door_access_control',
1642 'widget_options' => [
1643 'provider' => $integration->getProfileProvider(),
1644 'profile' => $integration->getProfileID(),
1645 'device' => $device->getID(),
1646 ],
1647 ],
1648 ]);
1649 } catch (Exception $e) {
1650 // do nothing
1651 }
1652 }
1653 } catch (Exception $e) {
1654 // check if the error exception contains retry data
1655 $retryData = [];
1656 if ($e instanceof VBODooraccessException) {
1657 // obtain the retry information
1658 $retryData = [
1659 'callback' => $e->getRetryCallback(),
1660 'options' => $e->getRetryData(),
1661 ];
1662 }
1663
1664 // store an entry within the notifications center for the failed operation
1665 try {
1666 VBOFactory::getNotificationCenter()
1667 ->store([
1668 [
1669 'sender' => 'dac',
1670 'type' => sprintf('dac.%s.nok', $historyType),
1671 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1672 'summary' => $e->getMessage() ?: 'An error occurred.',
1673 'idorder' => $registry->getID(),
1674 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1675 'label' => JText::translate('VBO_TAKE_ACTION'),
1676 'widget' => 'door_access_control',
1677 'widget_options' => [
1678 'provider' => $integration->getProfileProvider(),
1679 'profile' => $integration->getProfileID(),
1680 'device' => $device->getID(),
1681 'retry_data' => $retryData,
1682 ],
1683 ],
1684 ]);
1685 } catch (Exception $e) {
1686 // do nothing
1687 }
1688 }
1689 }
1690 }
1691 }
1692
1693 return (bool) $doorAccessActions;
1694 }
1695
1696 /**
1697 * Loads the upcoming reservations within the given check-in timestamp intervals.
1698 *
1699 * @param array $intervals List of two timestamps for the check-in bounds.
1700 *
1701 * @return array List of eligible reservation records, if any.
1702 */
1703 protected function loadUpcomingReservations(array $intervals)
1704 {
1705 $dbo = JFactory::getDbo();
1706
1707 $dbo->setQuery(
1708 $dbo->getQuery(true)
1709 ->select('*')
1710 ->from($dbo->qn('#__vikbooking_orders'))
1711 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1712 ->where($dbo->qn('closure') . ' = 0')
1713 ->where('(' . $dbo->qn('checkin') . ' BETWEEN ' . ((int) ($intervals[0] ?? time())) . ' AND ' . ((int) ($intervals[1] ?? strtotime('+1 hour'))) . ')')
1714 );
1715
1716 return $dbo->loadAssocList();
1717 }
1718
1719 /**
1720 * Loads the checked-out reservations within the given check-in timestamp intervals.
1721 *
1722 * @param array $intervals List of two timestamps for the check-out bounds.
1723 *
1724 * @return array List of eligible reservation records, if any.
1725 *
1726 * @since 1.18.7 (J) - 1.8.7 (WP)
1727 */
1728 protected function loadCheckedOutReservations(array $intervals)
1729 {
1730 $dbo = JFactory::getDbo();
1731
1732 $dbo->setQuery(
1733 $dbo->getQuery(true)
1734 ->select('*')
1735 ->from($dbo->qn('#__vikbooking_orders'))
1736 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1737 ->where($dbo->qn('closure') . ' = 0')
1738 ->where('(' . $dbo->qn('checkout') . ' BETWEEN ' . ((int) ($intervals[0] ?? strtotime('-1 week', strtotime('00:00:00')))) . ' AND ' . ((int) ($intervals[1] ?? strtotime('-1 day', strtotime('23:59:59')))) . ')')
1739 );
1740
1741 return $dbo->loadAssocList();
1742 }
1743
1744 /**
1745 * Decodes and unserializes the proper record columns.
1746 *
1747 * @param array $record The raw integration database record.
1748 *
1749 * @return array The decoded and unserialized record columns.
1750 */
1751 protected function decodeIntegrationRecord(array $record)
1752 {
1753 if (!empty($record['settings']) && is_scalar($record['settings'])) {
1754 $record['settings'] = (array) json_decode($record['settings'], true);
1755 }
1756
1757 if (!empty($record['devices']) && is_scalar($record['devices'])) {
1758 $record['devices'] = (array) unserialize($record['devices']);
1759 }
1760
1761 if (!empty($record['data']) && is_scalar($record['data'])) {
1762 $record['data'] = (array) json_decode($record['data'], true);
1763 }
1764
1765 return $record;
1766 }
1767
1768 /**
1769 * Loads the available door access integrations.
1770 *
1771 * @return void
1772 */
1773 protected function loadIntegrations()
1774 {
1775 // access the platform dispatcher
1776 $dispatcher = VBOFactory::getPlatform()->getDispatcher();
1777
1778 // integrations path and files
1779 $integrations_base = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'provider' . DIRECTORY_SEPARATOR;
1780 $integration_files = glob($integrations_base . '*.php');
1781 $integrations_banned = [];
1782
1783 /**
1784 * Trigger event to let other plugins register additional integrations.
1785 *
1786 * @return array A list of supported integrations.
1787 */
1788 $list = $dispatcher->filter('onLoadDoorAccessIntegrations');
1789
1790 foreach ($list as $chunk) {
1791 // merge default integration files with the returned ones
1792 $integration_files = array_merge($integration_files, (array) $chunk);
1793 }
1794
1795 /**
1796 * Trigger event to let other plugins unregister specific integrations.
1797 *
1798 * @return array A list of integration identifiers (aliases) to unload.
1799 */
1800 $unloaded = $dispatcher->filter('onUnloadDoorAccessIntegrations');
1801
1802 foreach ($unloaded as $chunk) {
1803 // merge all the the returned ones
1804 $integrations_banned = array_merge($integrations_banned, (array) $chunk);
1805 }
1806
1807 // scan the integration files and register the installed integrations
1808 foreach ($integration_files as $integration_file) {
1809 try {
1810 // require the file if it exists
1811 if (is_file($integration_file)) {
1812 require_once($integration_file);
1813 }
1814
1815 // integration identifier (alias)
1816 $integration_alias = basename($integration_file, '.php');
1817
1818 // check if the integration was unloaded
1819 if (in_array($integration_alias, $integrations_banned)) {
1820 continue;
1821 }
1822
1823 // build integration class name
1824 $classname = 'VBODooraccessProvider' . str_replace(' ', '', ucwords(str_replace('_', ' ', $integration_alias)));
1825
1826 if (class_exists($classname)) {
1827 // instantiate integration object
1828 $integration = new $classname;
1829
1830 // push the installed integration
1831 $this->integrations[] = $integration;
1832 }
1833 } catch (Throwable $e) {
1834 // do nothing but skip the current integration
1835 }
1836 }
1837
1838 /**
1839 * Sort the integrations by name and by offline status.
1840 *
1841 * @since 1.18.15 (J) - 1.8.15 (WP)
1842 */
1843 usort($this->integrations, function($a, $b) {
1844 if (($a->isOffline ?? null) && !($b->isOffline ?? null)) {
1845 return 1;
1846 }
1847
1848 if (($b->isOffline ?? null) && !($a->isOffline ?? null)) {
1849 return -1;
1850 }
1851
1852 return strcasecmp($a->getShortName(), $b->getShortName());
1853 });
1854 }
1855 }
1856