PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.10
VikBooking Hotel Booking Engine & PMS v1.8.10
1.8.16 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 All 37 releases
vikbooking / admin / helpers / src / dooraccess / factory.php

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

1,836 lines 76.7 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 VBODooraccessFactory
32 */
33 protected $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 } catch (Exception $e) {
1135 // do nothing
1136 }
1137 }
1138 }
1139 }
1140 }
1141
1142 return $firstAccessCount;
1143 }
1144
1145 /**
1146 * Handles the upcoming check-ins by triggering the operations involving provider devices.
1147 * This method is constantly executed as a cron-schedule by the CMS platform itself.
1148 * Will invoke only the provider integrations that generate passcodes "before the check-in".
1149 *
1150 * @return bool True if some door-access-control actions were performed, false otherwise.
1151 */
1152 public function handleUpcomingArrivals()
1153 {
1154 // count the door access handling actions performed
1155 $doorAccessActions = 0;
1156
1157 // scan the eligible integration records for generating door-access passcodes at the time of booking
1158 foreach ($this->loadGeneratingIntegrations('checkin') as $record) {
1159 // get the integration provider
1160 $integration = $this->getIntegrationProvider($record['provider_alias']);
1161 if (!$integration) {
1162 // unknown integration provider
1163 continue;
1164 }
1165
1166 // inject profile record within the integration provider
1167 $integration->setProfileRecord($record);
1168
1169 // load the upcoming check-ins within the configured profile period, if any
1170 foreach ($this->loadUpcomingReservations($integration->getNextGenerationPeriodTimestamps()) as $booking) {
1171 // wrap the booking information into a registry
1172 $registry = VBOBookingRegistry::getInstance($booking);
1173
1174 // ensure this booking was never processed before
1175 if ($integration->getBookingAccessProcessed($registry->getID())) {
1176 // skip this booking as it was already processed
1177 continue;
1178 }
1179
1180 // flag the booking as processed
1181 $integration->setBookingAccessProcessed($registry->getID());
1182
1183 // get list of booked listing ids and subunits
1184 $bookedListingSubunits = $registry->getBookedListingSubunits();
1185
1186 // iterate all provider integration devices
1187 foreach ($integration->getDevices() as $device) {
1188 // get the listing-subunit pairs compatible with the current device
1189 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1190
1191 // iterate all listing units connected to the current device, if any
1192 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1193 // obtain listing ID and subunit number
1194 list($listingId, $subunitId) = $listingSubunitPair;
1195
1196 // set current room index to identify a multi-room booking context
1197 $registry->setCurrentRoomIndex($listingIndex);
1198
1199 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1200 $registry->setCurrentRoomNumber($subunitId);
1201
1202 // call door access control on provider record for the current device, listing and booking
1203 try {
1204 // set proper history/notification type first
1205 $historyType = 'ND';
1206
1207 // new booking
1208 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1209
1210 // parse the device capability execution result
1211 if ($result) {
1212 // increase counter
1213 $doorAccessActions++;
1214
1215 // store booking history record
1216 VikBooking::getBookingHistoryInstance($registry->getID())
1217 ->setBookingData($registry->getData(), $registry->getRooms())
1218 ->setExtraData([
1219 'provider' => $integration->getProfileProvider(),
1220 'profile' => $integration->getProfileID(),
1221 'device' => $device->getID(),
1222 'passcode' => $result->getPasscode(),
1223 'props' => $result->getProperties(),
1224 ])
1225 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1226
1227 // store an entry within the notifications center for the successful operation
1228 try {
1229 VBOFactory::getNotificationCenter()
1230 ->store([
1231 [
1232 'sender' => 'dac',
1233 'type' => sprintf('dac.%s.ok', $historyType),
1234 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1235 'summary' => strip_tags((string) $result),
1236 'idorder' => $registry->getID(),
1237 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1238 'label' => $integration->getName(),
1239 'widget' => 'door_access_control',
1240 'widget_options' => [
1241 'provider' => $integration->getProfileProvider(),
1242 'profile' => $integration->getProfileID(),
1243 'device' => $device->getID(),
1244 ],
1245 ],
1246 ]);
1247 } catch (Exception $e) {
1248 // do nothing
1249 }
1250 }
1251 } catch (Exception $e) {
1252 // check if the error exception contains retry data
1253 $retryData = [];
1254 if ($e instanceof VBODooraccessException) {
1255 // obtain the retry information
1256 $retryData = [
1257 'callback' => $e->getRetryCallback(),
1258 'options' => $e->getRetryData(),
1259 ];
1260 }
1261
1262 // store an entry within the notifications center for the failed operation
1263 try {
1264 VBOFactory::getNotificationCenter()
1265 ->store([
1266 [
1267 'sender' => 'dac',
1268 'type' => sprintf('dac.%s.nok', $historyType),
1269 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1270 'summary' => $e->getMessage() ?: 'An error occurred.',
1271 'idorder' => $registry->getID(),
1272 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1273 'label' => JText::translate('VBO_TAKE_ACTION'),
1274 'widget' => 'door_access_control',
1275 'widget_options' => [
1276 'provider' => $integration->getProfileProvider(),
1277 'profile' => $integration->getProfileID(),
1278 'device' => $device->getID(),
1279 'retry_data' => $retryData,
1280 ],
1281 ],
1282 ]);
1283 } catch (Exception $e) {
1284 // do nothing
1285 }
1286 }
1287 }
1288 }
1289 }
1290
1291 // update integration record
1292 $this->saveIntegrationRecord($integration, ['data' => $integration->getData()]);
1293 }
1294
1295 return (bool) $doorAccessActions;
1296 }
1297
1298 /**
1299 * Triggers the operations involving provider devices during a new booking confirmation event.
1300 *
1301 * @param array $booking The booking record.
1302 * @param array $booking_rooms The booking room records.
1303 *
1304 * @return bool
1305 */
1306 public function processBookingConfirmation(array $booking, array $booking_rooms = [])
1307 {
1308 // wrap the booking information into a registry
1309 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1310
1311 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1312 // do nothing when we're not dealing with a real confirmed and accepted reservation
1313 return false;
1314 }
1315
1316 // new booking
1317 return $this->handleBookingEvent('confirmation', $registry);
1318 }
1319
1320 /**
1321 * Triggers the operations involving provider devices during a booking modification event.
1322 *
1323 * @param array $booking The booking record.
1324 * @param array $booking_rooms The booking room records.
1325 * @param array $prev_booking The previous booking record.
1326 *
1327 * @return bool
1328 */
1329 public function processBookingModification(array $booking, array $booking_rooms = [], array $prev_booking = [])
1330 {
1331 // wrap the booking information into a registry
1332 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms, $prev_booking);
1333
1334 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1335 // do nothing when we're not dealing with a real confirmed and accepted reservation
1336 return false;
1337 }
1338
1339 // detect changes from previous to current booking even at room-level (subunits)
1340 if (!$registry->detectAlterations($roomLevel = true)) {
1341 // do nothing when no significant changes were made to the booking
1342 return false;
1343 }
1344
1345 // modified booking
1346 return $this->handleBookingEvent('modification', $registry);
1347 }
1348
1349 /**
1350 * Triggers the operations involving provider devices during a booking cancellation event.
1351 *
1352 * @param array $booking The booking record.
1353 * @param array $booking_rooms The booking room records.
1354 *
1355 * @return bool
1356 */
1357 public function processBookingCancellation(array $booking, array $booking_rooms = [])
1358 {
1359 // wrap the booking information into a registry
1360 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1361
1362 if ($registry->isClosure() || (!$registry->isCancelled() && !$registry->getPrevious())) {
1363 // do nothing when we're not dealing with a real booking cancellation or modification
1364 return false;
1365 }
1366
1367 // cancelled booking
1368 return $this->handleBookingEvent('cancellation', $registry);
1369 }
1370
1371 /**
1372 * Triggers the operations involving provider devices during a pre-checkin completed event.
1373 *
1374 * @param array $booking The booking record.
1375 * @param array $booking_rooms The booking room records.
1376 *
1377 * @return bool
1378 *
1379 * @since 1.18.6 (J) - 1.8.6 (WP)
1380 */
1381 public function processPrecheckinCompleted(array $booking, array $booking_rooms = [])
1382 {
1383 // wrap the booking information into a registry
1384 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1385
1386 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1387 // do nothing when we're not dealing with a real confirmed and accepted reservation
1388 return false;
1389 }
1390
1391 // ensure this is the first time the pre-checkin details are submitted, and not simply updated
1392 $totalPrecheckins = VikBooking::getBookingHistoryInstance($registry->getID())->getEventsWithData(['PC']);
1393 if ($totalPrecheckins && count($totalPrecheckins) > 1) {
1394 // this booking already got the pre-checkin information submitted once
1395 return false;
1396 }
1397
1398 // handle the pre-checkin completed event
1399 return $this->handleBookingEvent('precheckin', $registry);
1400 }
1401
1402 /**
1403 * Spawns the callback by reaching the OAuth2 authorization link (callback URL).
1404 *
1405 * @param ?array $data Optional spawn data to parse.
1406 *
1407 * @return void
1408 *
1409 * @throws Exception
1410 *
1411 * @since 1.18.6 (J) - 1.8.6 (WP)
1412 */
1413 public function spawnOAuthCallback(?array $data = null)
1414 {
1415 $app = JFactory::getApplication();
1416
1417 // gather request or data variables to load the proper integration
1418 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1419 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1420
1421 if (!$provider) {
1422 throw new Exception('Missing DAC integration provider to spawn.', 400);
1423 }
1424
1425 if (!$profileId) {
1426 throw new Exception('Missing DAC integration profile to spawn.', 400);
1427 }
1428
1429 // get the requested integration provider
1430 $integration = $this->getIntegrationProvider($provider);
1431
1432 if (!$integration) {
1433 throw new Exception('Invalid door access control provider identifier.', 404);
1434 }
1435
1436 // load the requested integration profile
1437 $profile = $this->loadIntegrationRecord((int) $profileId);
1438
1439 if (!$profile) {
1440 throw new Exception('Invalid door access control provider profile.', 404);
1441 }
1442
1443 // inject profile record within the integration
1444 $integration->setProfileRecord($profile);
1445
1446 // let the integration spawn the OAuth authorization callback to perform its actions
1447 $integration->spawnOAuthCallback($data);
1448 }
1449
1450 /**
1451 * Spawns the Webhook endpoint URL callback.
1452 *
1453 * @param ?array $data Optional spawn data to parse.
1454 *
1455 * @return void
1456 *
1457 * @throws Exception
1458 *
1459 * @since 1.18.6 (J) - 1.8.6 (WP)
1460 */
1461 public function spawnWebhookCallback(?array $data = null)
1462 {
1463 $app = JFactory::getApplication();
1464
1465 // gather request or data variables to load the proper integration
1466 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1467 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1468
1469 if (!$provider) {
1470 throw new Exception('Missing DAC integration provider to spawn.', 400);
1471 }
1472
1473 if (!$profileId) {
1474 throw new Exception('Missing DAC integration profile to spawn.', 400);
1475 }
1476
1477 // get the requested integration provider
1478 $integration = $this->getIntegrationProvider($provider);
1479
1480 if (!$integration) {
1481 throw new Exception('Invalid door access control provider identifier.', 404);
1482 }
1483
1484 // load the requested integration profile
1485 $profile = $this->loadIntegrationRecord((int) $profileId);
1486
1487 if (!$profile) {
1488 throw new Exception('Invalid door access control provider profile.', 404);
1489 }
1490
1491 // inject profile record within the integration
1492 $integration->setProfileRecord($profile);
1493
1494 // let the integration spawn the Webhook endpoint URL callback to perform its actions
1495 $integration->spawnWebhookCallback($data);
1496 }
1497
1498 /**
1499 * Handles a booking event by triggering the operations involving provider devices.
1500 * Will invoke only the provider integrations that can generate passcodes either
1501 * "at the time of booking" or "upon pre-checkin completed".
1502 *
1503 * @param string $type The booking event type (confirmation, modification, cancellation, precheckin).
1504 * @param VBOBookingRegistry $registry The booking registry containing all room related details.
1505 *
1506 * @return bool True if some door-access-control actions were performed, false otherwise.
1507 *
1508 * @since 1.18.6 (J) - 1.8.6 (WP) added support to pre-checkin event.
1509 */
1510 protected function handleBookingEvent(string $type, VBOBookingRegistry $registry)
1511 {
1512 // list of supported booking event types
1513 $validTypes = [
1514 'confirmation',
1515 'modification',
1516 'cancellation',
1517 'precheckin',
1518 ];
1519
1520 if (!in_array($type, $validTypes)) {
1521 // unsupported booking event
1522 return false;
1523 }
1524
1525 // determine the allowed generation type(s) for the integrations
1526 if ($type === 'confirmation') {
1527 // only integrations that generate passcodes "at the time of booking" should run
1528 $generationTypes = ['booking'];
1529 } elseif ($type === 'precheckin') {
1530 // only integrations that generate passcodes "upon pre-checkin completed" should run
1531 $generationTypes = ['precheckin'];
1532 } else {
1533 // both "at the time of booking" and "upon pre-checkin completed" integrations should run
1534 $generationTypes = ['booking', 'precheckin'];
1535 }
1536
1537 // count the door access handling actions performed
1538 $doorAccessActions = 0;
1539
1540 // get list of booked listing ids and subunits
1541 $bookedListingSubunits = $registry->getBookedListingSubunits();
1542
1543 // check if the booking was only modified at room-level (subunits)
1544 $modifiedRoomLevelOnly = false;
1545 if ($type === 'modification' && !$registry->detectAlterations($roomLevel = false)) {
1546 // turn flag on, because no booking-level changes were detected, only room-level changes (subunits)
1547 $modifiedRoomLevelOnly = true;
1548 }
1549
1550 // scan the eligible integrations for generating passcodes at the time of booking or pre-checkin
1551 foreach ($this->loadGeneratingIntegrations($generationTypes) as $record) {
1552 // get the integration provider
1553 $integration = $this->getIntegrationProvider($record['provider_alias']);
1554 if (!$integration) {
1555 // unknown integration provider
1556 continue;
1557 }
1558
1559 // inject profile record within the integration provider
1560 $integration->setProfileRecord($record);
1561
1562 // ensure a booking modification event will not generate a new passcode too early
1563 if (($record['gentype'] ?? '') === 'precheckin' && in_array($type, ['modification', 'cancellation']) && !$registry->hasPreCheckedIn()) {
1564 // avoid premature actions because the guest has not gone through pre-checkin yet
1565 continue;
1566 }
1567
1568 // iterate all provider integration devices
1569 foreach ($integration->getDevices() as $device) {
1570 if ($modifiedRoomLevelOnly && !$device->getConnectedSubunits()) {
1571 // prevent useless passcode modifications in case of room-level only changes and no subunits mapped
1572 continue;
1573 }
1574
1575 // get the listing-subunit pairs compatible with the current device
1576 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1577
1578 // iterate all listing units connected to the current device, if any
1579 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1580 // obtain listing ID and subunit number
1581 list($listingId, $subunitId) = $listingSubunitPair;
1582
1583 // set current room index to identify a multi-room booking context
1584 $registry->setCurrentRoomIndex($listingIndex);
1585
1586 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1587 $registry->setCurrentRoomNumber($subunitId);
1588
1589 // call door access control on provider record for the current device, listing and booking
1590 try {
1591 if ($type === 'modification') {
1592 // set proper history/notification type first
1593 $historyType = 'MD';
1594 // booking modified
1595 $result = $integration->modifyBookingDoorAccess($device, $listingId, $registry);
1596 } elseif ($type === 'cancellation') {
1597 // set proper history/notification type first
1598 $historyType = 'CD';
1599 // booking cancelled
1600 $result = $integration->cancelBookingDoorAccess($device, $listingId, $registry);
1601 } else {
1602 // new booking or pre-checkin completed event
1603 // set proper history/notification type first
1604 $historyType = 'ND';
1605 // new booking
1606 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1607 }
1608
1609 // parse the device capability execution result
1610 if ($result) {
1611 // increase counter
1612 $doorAccessActions++;
1613
1614 // store booking history record
1615 VikBooking::getBookingHistoryInstance($registry->getID())
1616 ->setBookingData($registry->getData(), $registry->getRooms())
1617 ->setExtraData([
1618 'provider' => $integration->getProfileProvider(),
1619 'profile' => $integration->getProfileID(),
1620 'device' => $device->getID(),
1621 'passcode' => $result->getPasscode(),
1622 'props' => $result->getProperties(),
1623 ])
1624 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1625
1626 // store an entry within the notifications center for the successful operation
1627 try {
1628 VBOFactory::getNotificationCenter()
1629 ->store([
1630 [
1631 'sender' => 'dac',
1632 'type' => sprintf('dac.%s.ok', $historyType),
1633 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1634 'summary' => strip_tags((string) $result),
1635 'idorder' => $registry->getID(),
1636 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1637 'label' => $integration->getName(),
1638 'widget' => 'door_access_control',
1639 'widget_options' => [
1640 'provider' => $integration->getProfileProvider(),
1641 'profile' => $integration->getProfileID(),
1642 'device' => $device->getID(),
1643 ],
1644 ],
1645 ]);
1646 } catch (Exception $e) {
1647 // do nothing
1648 }
1649 }
1650 } catch (Exception $e) {
1651 // check if the error exception contains retry data
1652 $retryData = [];
1653 if ($e instanceof VBODooraccessException) {
1654 // obtain the retry information
1655 $retryData = [
1656 'callback' => $e->getRetryCallback(),
1657 'options' => $e->getRetryData(),
1658 ];
1659 }
1660
1661 // store an entry within the notifications center for the failed operation
1662 try {
1663 VBOFactory::getNotificationCenter()
1664 ->store([
1665 [
1666 'sender' => 'dac',
1667 'type' => sprintf('dac.%s.nok', $historyType),
1668 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1669 'summary' => $e->getMessage() ?: 'An error occurred.',
1670 'idorder' => $registry->getID(),
1671 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1672 'label' => JText::translate('VBO_TAKE_ACTION'),
1673 'widget' => 'door_access_control',
1674 'widget_options' => [
1675 'provider' => $integration->getProfileProvider(),
1676 'profile' => $integration->getProfileID(),
1677 'device' => $device->getID(),
1678 'retry_data' => $retryData,
1679 ],
1680 ],
1681 ]);
1682 } catch (Exception $e) {
1683 // do nothing
1684 }
1685 }
1686 }
1687 }
1688 }
1689
1690 return (bool) $doorAccessActions;
1691 }
1692
1693 /**
1694 * Loads the upcoming reservations within the given check-in timestamp intervals.
1695 *
1696 * @param array $intervals List of two timestamps for the check-in bounds.
1697 *
1698 * @return array List of eligible reservation records, if any.
1699 */
1700 protected function loadUpcomingReservations(array $intervals)
1701 {
1702 $dbo = JFactory::getDbo();
1703
1704 $dbo->setQuery(
1705 $dbo->getQuery(true)
1706 ->select('*')
1707 ->from($dbo->qn('#__vikbooking_orders'))
1708 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1709 ->where($dbo->qn('closure') . ' = 0')
1710 ->where('(' . $dbo->qn('checkin') . ' BETWEEN ' . ((int) ($intervals[0] ?? time())) . ' AND ' . ((int) ($intervals[1] ?? strtotime('+1 hour'))) . ')')
1711 );
1712
1713 return $dbo->loadAssocList();
1714 }
1715
1716 /**
1717 * Loads the checked-out reservations within the given check-in timestamp intervals.
1718 *
1719 * @param array $intervals List of two timestamps for the check-out bounds.
1720 *
1721 * @return array List of eligible reservation records, if any.
1722 *
1723 * @since 1.18.7 (J) - 1.8.7 (WP)
1724 */
1725 protected function loadCheckedOutReservations(array $intervals)
1726 {
1727 $dbo = JFactory::getDbo();
1728
1729 $dbo->setQuery(
1730 $dbo->getQuery(true)
1731 ->select('*')
1732 ->from($dbo->qn('#__vikbooking_orders'))
1733 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1734 ->where($dbo->qn('closure') . ' = 0')
1735 ->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')))) . ')')
1736 );
1737
1738 return $dbo->loadAssocList();
1739 }
1740
1741 /**
1742 * Decodes and unserializes the proper record columns.
1743 *
1744 * @param array $record The raw integration database record.
1745 *
1746 * @return array The decoded and unserialized record columns.
1747 */
1748 protected function decodeIntegrationRecord(array $record)
1749 {
1750 if (!empty($record['settings']) && is_scalar($record['settings'])) {
1751 $record['settings'] = (array) json_decode($record['settings'], true);
1752 }
1753
1754 if (!empty($record['devices']) && is_scalar($record['devices'])) {
1755 $record['devices'] = (array) unserialize($record['devices']);
1756 }
1757
1758 if (!empty($record['data']) && is_scalar($record['data'])) {
1759 $record['data'] = (array) json_decode($record['data'], true);
1760 }
1761
1762 return $record;
1763 }
1764
1765 /**
1766 * Loads the available door access integrations.
1767 *
1768 * @return void
1769 */
1770 protected function loadIntegrations()
1771 {
1772 // access the platform dispatcher
1773 $dispatcher = VBOFactory::getPlatform()->getDispatcher();
1774
1775 // integrations path and files
1776 $integrations_base = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'provider' . DIRECTORY_SEPARATOR;
1777 $integration_files = glob($integrations_base . '*.php');
1778 $integrations_banned = [];
1779
1780 /**
1781 * Trigger event to let other plugins register additional integrations.
1782 *
1783 * @return array A list of supported integrations.
1784 */
1785 $list = $dispatcher->filter('onLoadDoorAccessIntegrations');
1786
1787 foreach ($list as $chunk) {
1788 // merge default integration files with the returned ones
1789 $integration_files = array_merge($integration_files, (array) $chunk);
1790 }
1791
1792 /**
1793 * Trigger event to let other plugins unregister specific integrations.
1794 *
1795 * @return array A list of integration identifiers (aliases) to unload.
1796 */
1797 $unloaded = $dispatcher->filter('onUnloadDoorAccessIntegrations');
1798
1799 foreach ($unloaded as $chunk) {
1800 // merge all the the returned ones
1801 $integrations_banned = array_merge($integrations_banned, (array) $chunk);
1802 }
1803
1804 // scan the integration files and register the installed integrations
1805 foreach ($integration_files as $integration_file) {
1806 try {
1807 // require the file if it exists
1808 if (is_file($integration_file)) {
1809 require_once($integration_file);
1810 }
1811
1812 // integration identifier (alias)
1813 $integration_alias = basename($integration_file, '.php');
1814
1815 // check if the integration was unloaded
1816 if (in_array($integration_alias, $integrations_banned)) {
1817 continue;
1818 }
1819
1820 // build integration class name
1821 $classname = 'VBODooraccessProvider' . str_replace(' ', '', ucwords(str_replace('_', ' ', $integration_alias)));
1822
1823 if (class_exists($classname)) {
1824 // instantiate integration object
1825 $integration = new $classname;
1826
1827 // push the installed integration
1828 $this->integrations[] = $integration;
1829 }
1830 } catch (Throwable $e) {
1831 // do nothing but skip the current integration
1832 }
1833 }
1834 }
1835 }
1836