PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
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.16, at admin/helpers/src/dooraccess/factory.php

1,915 lines 79.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 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 * Tells whether the given booking should have gone through passcode generation.
631 * The method will NOT check if device passcodes were already generated. Useful
632 * to match bookings generated before configuring a DAC integration provider.
633 *
634 * @param VBOBookingRegistry $registry The booking registry.
635 *
636 * @return bool
637 *
638 * @since 1.18.16 (J) - 1.8.16 (WP)
639 */
640 public function shouldBookingHavePasscodes(VBOBookingRegistry $registry)
641 {
642 if (!$registry->isConfirmed()) {
643 // only confirmed bookings can get passcodes
644 return false;
645 }
646
647 // get the unique list of booked listing ids and subunits
648 $bookedListingSubunits = $registry->getBookedListingSubunits();
649
650 // check if the booking has pre-check-in values
651 $hasPreCheckIn = $registry->hasPreCheckedIn();
652
653 // scan the eligible integrations for generating passcodes at the time of booking or pre-checkin
654 foreach ($this->loadGeneratingIntegrations(['booking', 'precheckin']) as $record) {
655 // get the integration provider
656 $integration = $this->getIntegrationProvider($record['provider_alias']);
657 if (!$integration) {
658 // unknown integration provider
659 continue;
660 }
661
662 // inject profile record within the integration provider
663 $integration->setProfileRecord($record);
664
665 // iterate all provider integration devices
666 foreach ($integration->getDevices() as $device) {
667 // get the listing-subunit pairs compatible with the current device
668 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
669
670 if (!$deviceListingUnits) {
671 // ignore device
672 continue;
673 }
674
675 if (($record['gentype'] ?? '') === 'precheckin' && !$hasPreCheckIn) {
676 // this device will get a passcode generated once pre-check-in will be completed
677 continue;
678 }
679
680 // device matching booked listing should have had a passcode generated
681 return true;
682 }
683 }
684
685 return false;
686 }
687
688 /**
689 * Attempts to return a list of associative arrays that include the active passcode string
690 * value and the device information based on what was generated for a specific booking.
691 *
692 * @param VBOBookingRegistry $registry The booking registry.
693 *
694 * @return array List of passcode and device name associative
695 * arrays (usually one), or empty array.
696 */
697 public function getBookingDevicePasscodes(VBOBookingRegistry $registry)
698 {
699 // find the passcode data that were previously created for this booking by any integration
700 $previousPasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
701 ->getEventsWithData(['ND', 'MD'], function($data) {
702 // cast history data payload to an array
703 $data = (array) $data;
704
705 // ensure the passcode was generated by/for a valid provider, profile and device
706 return !empty($data['provider']) &&
707 !empty($data['profile']) &&
708 !empty($data['device']) &&
709 (!empty($data['passcode']) || !empty($data['props']));
710 });
711
712 if (!$previousPasscodes) {
713 // nothing was ever created for this booking
714 return [];
715 }
716
717 // get the unique list of booked listing ids and subunits
718 $bookedListingIds = $registry->getBookedListingIds();
719 $bookedListingSubunits = $registry->getBookedListingSubunits();
720
721 // build the pool of devices and passcodes data
722 $devicePasscodesSignatures = [];
723 $devicePasscodesPool = [];
724
725 // iterate over the latest booking events that generated a passcode
726 foreach (array_reverse($previousPasscodes) as $previousData) {
727 // ensure we only have array values
728 $previousData = (array) json_decode(json_encode($previousData), true);
729
730 // build passcode signature with provider and profile identifiers
731 $passcodeSignature = sprintf('%s-%d', (string) $previousData['provider'], (int) $previousData['profile']);
732
733 // access the provider integration
734 $integration = $this->getIntegrationProvider((string) $previousData['provider']);
735 if (!$integration) {
736 // unknown provider
737 continue;
738 }
739
740 // inject profile record within the integration provider
741 $integration->setProfileRecord($this->loadIntegrationRecord((int) $previousData['profile']));
742
743 // ensure this device still exists within the integration provider
744 if (!$integration->deviceExists((string) $previousData['device'])) {
745 // unknown device
746 continue;
747 }
748
749 // count the number of expected passcodes that were generated for this booking by the current integration provider
750 $expectedPasscodes = 0;
751 foreach ($integration->getDevices() as $device) {
752 // count, if any, how many listings (with subunits) are compatible with the current device
753 $expectedPasscodes += $device->countMatchingListingUnits($bookedListingSubunits);
754 }
755
756 // ensure we are not getting too many passcodes for this provider, which may have been cancelled
757 if (($devicePasscodesSignatures[$passcodeSignature] ?? 0) >= $expectedPasscodes) {
758 // fetch no more passcodes for this provider and profile
759 continue;
760 }
761
762 // get the device name and listings involved with the reservation
763 $deviceName = '';
764 $deviceListings = [];
765 try {
766 $device = $integration->getDeviceById((string) $previousData['device']);
767 $deviceName = $device->getName();
768 $deviceListings = array_intersect($bookedListingIds, $device->getConnectedListings());
769 $deviceListings = array_map(function($listingId) {
770 return VikBooking::getRoomInfo($listingId, ['name'], true)['name'] ?? $listingId;
771 }, $deviceListings);
772 } catch (Exception $e) {
773 // fallback to device ID
774 $deviceName = (string) $previousData['device'];
775 }
776
777 // get the passcode value generated
778 $passcodeValue = ($previousData['passcode'] ?? '') ?: $integration->getPasscodeFromHistoryResult((array) ($previousData['props'] ?? []));
779 if (!$passcodeValue) {
780 // unexpected situation
781 continue;
782 }
783
784 // push passcode and device information
785 $devicePasscodesPool[] = [
786 'deviceName' => $deviceName,
787 'deviceId' => $previousData['device'],
788 'passcode' => $passcodeValue,
789 'listings' => $deviceListings,
790 ];
791
792 // increase provider-profile counter
793 $devicePasscodesSignatures[$passcodeSignature] = ($devicePasscodesSignatures[$passcodeSignature] ?? 0) + 1;
794 }
795
796 return $devicePasscodesPool;
797 }
798
799 /**
800 * Attempts to handle the command for unlocking one or more devices to which the rooms booked are assigned.
801 * The method will NOT validate the booking stay dates, such controls should be made prior to calling it.
802 *
803 * @param VBOBookingRegistry $registry The booking registry.
804 * @param ?array $options Associative list of request options.
805 *
806 * @return array List of devices unlock results, if any.
807 *
808 * @throws Exception
809 */
810 public function handleBookingDeviceUnlock(VBOBookingRegistry $registry, ?array $options = null)
811 {
812 // get list of booked listing ids and subunits
813 $bookedListingSubunits = $registry->getBookedListingSubunits();
814
815 // list of provider integrations capable of unlocking a device
816 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
817 return $integration->canUnlockDevices();
818 });
819
820 if (!$integrations) {
821 // raise an error
822 throw new Exception('Unable to unlock remote devices.', 501);
823 }
824
825 // build the device unlock results
826 $unlockResults = [];
827
828 // flag to indicate that the requested device was not found
829 $exactDeviceFound = null;
830
831 // iterate all provider integrations
832 foreach ($integrations as $integration) {
833 // iterate all integration devices
834 foreach ($integration->getDevices() as $device) {
835 // get the listing-subunit pairs compatible with the current device
836 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
837
838 if (!$deviceListingUnits) {
839 // the device is not connected to any of the booked listings
840 continue;
841 }
842
843 if (($options['device_id'] ?? null) && trim((string) $options['device_id']) != trim($device->getID())) {
844 // this is not the requested device ID to unlock
845 $exactDeviceFound = false;
846 continue;
847 }
848
849 if (($options['device_name'] ?? null)) {
850 $seek_name = trim((string) $options['device_name']);
851 if (stripos($device->getName(), $seek_name) === false && stripos($seek_name, $device->getName()) === false) {
852 // this is not the requested device name to unlock
853 $exactDeviceFound = false;
854 continue;
855 }
856 }
857
858 try {
859 // unlock the device
860 $result = $integration->handleUnlockDevice($device);
861
862 if (!$result) {
863 // throw an error
864 throw new Exception('The device cannot be unlocked.', 501);
865 }
866
867 // push the successful unlock result
868 $unlockResults[] = [
869 'deviceName' => $device->getName(),
870 'deviceId' => $device->getID(),
871 'unlocked' => true,
872 'message' => (string) $result,
873 ];
874 } catch (Exception $e) {
875 // push the faulty unlock result
876 $unlockResults[] = [
877 'deviceName' => $device->getName(),
878 'deviceId' => $device->getID(),
879 'unlocked' => false,
880 'message' => $e->getMessage() ?: 'Unlocking the device failed.',
881 ];
882 }
883 }
884 }
885
886 if (!$unlockResults) {
887 // raise an error
888 if ($exactDeviceFound === false) {
889 throw new Exception('Could not find the requested device to unlock.', 404);
890 }
891 throw new Exception('None of the booked listings has got a device/door to unlock/open. No compatible devices were found.', 400);
892 }
893
894 return $unlockResults;
895 }
896
897 /**
898 * Takes care of cleaning the expired passcodes to free up memory on the device.
899 *
900 * @param ?array $options Optional associative list of processing options.
901 *
902 * @return int Number of passcodes deleted.
903 *
904 * @since 1.18.7 (J) - 1.8.7 (WP)
905 */
906 public function cleanExpiredPasscodes(?array $options = null)
907 {
908 // count total passcodes deleted
909 $passcodesDeleted = 0;
910
911 // list of configured provider integrations to delete expired passcodes
912 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
913 return $integration->canCleanExpiredPasscodes();
914 });
915
916 if (!$integrations) {
917 // do not proceed
918 return $passcodesDeleted;
919 }
920
921 // calculate timestamp bounds, last week by default
922 $tsBounds = [
923 strtotime('00:00:00', strtotime(($options['date_from'] ?? date('Y-m-d', strtotime('-1 week'))))),
924 strtotime('23:59:59', strtotime(($options['date_to'] ?? date('Y-m-d', strtotime('-1 day')))))
925 ];
926
927 // load the departed reservations in the last week
928 $lastDepartures = $this->loadCheckedOutReservations($tsBounds);
929
930 // map bookings with previously generated passcodes
931 $lastDepartures = array_map(function($booking) {
932 // set booking DAC passcodes
933 $booking['_dac_passcodes_data'] = [];
934
935 // check if any passcode was previously created
936 $previousPasscodes = VikBooking::getBookingHistoryInstance($booking['id'])
937 ->getEventsWithData(['ND', 'MD'], function($data) {
938 // cast history data payload to an array
939 $data = (array) $data;
940
941 // ensure the passcode was generated by/for a valid provider, profile and device
942 return !empty($data['provider']) &&
943 !empty($data['profile']) &&
944 !empty($data['device']) &&
945 (!empty($data['passcode']) || !empty($data['props']));
946 });
947
948 foreach (array_reverse((array) $previousPasscodes) as $previousData) {
949 // ensure we only have array values
950 $previousData = (array) json_decode(json_encode($previousData), true);
951
952 // push booking passcode data
953 $passcodeData = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
954
955 if ($passcodeData) {
956 // push booking passcode data
957 $booking['_dac_passcodes_data'][] = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
958 }
959 }
960
961 // returned the mapped booking array
962 return $booking;
963 }, $lastDepartures);
964
965 // filter out bookings for which no passcodes were ever generated
966 $lastDepartures = array_filter($lastDepartures, function($booking) {
967 // ensure booking passcodes data is set
968 return !empty($booking['_dac_passcodes_data']);
969 });
970
971 if (!$lastDepartures) {
972 // nothing to delete at this time
973 return $passcodesDeleted;
974 }
975
976 // build the readable date bounds
977 $startInfo = getdate($tsBounds[0]);
978 $endInfo = getdate($tsBounds[1]);
979 $targetDtFrom = sprintf('%s %d', VikBooking::sayMonth($startInfo['mon'], true), $startInfo['mday']);
980 $targetDtTo = sprintf('%s %d', VikBooking::sayMonth($endInfo['mon'], true), $endInfo['mday']);
981 if ($startInfo['year'] != $endInfo['year']) {
982 // append short year to both target dates
983 $targetDtFrom .= ' ' . date('y', $tsBounds[0]);
984 $targetDtTo .= ' ' . date('y', $tsBounds[1]);
985 } else {
986 // append full year to target end date
987 $targetDtTo .= ' ' . $endInfo['year'];
988 }
989
990 // iterate departed reservations
991 foreach ($lastDepartures as $booking) {
992 // wrap the booking information into a registry
993 $registry = VBOBookingRegistry::getInstance($booking);
994
995 // get list of booked listing ids and subunits
996 $bookedListingSubunits = $registry->getBookedListingSubunits();
997
998 // scan all the eligible integration records
999 foreach ($integrations as $integration) {
1000 // set DAC passcodes data within the booking registry
1001 $registry->setDACProperty($integration->getAlias(), 'passcodes_data', $booking['_dac_passcodes_data'] ?? []);
1002
1003 // start integration counter
1004 $integrationDeletion = 0;
1005
1006 // iterate all provider integration devices
1007 foreach ($integration->getDevices() as $device) {
1008 // get the listing-subunit pairs compatible with the current device
1009 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1010
1011 // iterate all listing units connected to the current device, if any
1012 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1013 // obtain listing ID and subunit number
1014 list($listingId, $subunitId) = $listingSubunitPair;
1015
1016 // set current room index to identify a multi-room booking context
1017 $registry->setCurrentRoomIndex($listingIndex);
1018
1019 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1020 $registry->setCurrentRoomNumber($subunitId);
1021
1022 try {
1023 // attempt to delete a previously created passcode on this device for the current booking
1024 if ($integration->cancelBookingDoorAccess($device, $listingId, $registry)) {
1025 // increase global counter
1026 $passcodesDeleted++;
1027
1028 // increase integration counter
1029 $integrationDeletion++;
1030 }
1031 } catch (Exception $e) {
1032 // do nothing
1033 }
1034 }
1035 }
1036
1037 if ($integrationDeletion) {
1038 // store an entry within the notifications center for the successful operation
1039 VBOFactory::getNotificationCenter()
1040 ->store([
1041 [
1042 'sender' => 'dac',
1043 'type' => 'dac.EX.ok',
1044 'title' => sprintf('%s', (string) $integration->getProfileName()),
1045 'summary' => JText::sprintf('VBO_EXP_PASSCODES_DEL_OK_RES', $integrationDeletion, $targetDtFrom, $targetDtTo),
1046 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1047 ],
1048 ]);
1049 }
1050 }
1051 }
1052
1053 return $passcodesDeleted;
1054 }
1055
1056 /**
1057 * Watches the daily arrivals to notify on the first access through booking passcodes.
1058 *
1059 * @param ?array $options Optional associative list of watching options.
1060 *
1061 * @return int Number of first access found.
1062 *
1063 * @since 1.18.6 (J) - 1.8.6 (WP)
1064 */
1065 public function watchFirstAccess(?array $options = null)
1066 {
1067 // count the first access found
1068 $firstAccessCount = 0;
1069
1070 // list of configured provider integrations to watch first access
1071 $integrations = array_filter($this->loadActiveIntegrations(), function($integration) {
1072 return $integration->canWatchFirstAccess();
1073 });
1074
1075 if (!$integrations) {
1076 // do not proceed
1077 return $firstAccessCount;
1078 }
1079
1080 // load the arrivals for today
1081 $todayArrivals = $this->loadUpcomingReservations([
1082 strtotime('00:00:00', strtotime(($options['date_from'] ?? date('Y-m-d')))),
1083 strtotime('23:59:59', strtotime(($options['date_to'] ?? date('Y-m-d'))))
1084 ]);
1085
1086 // map today bookings with previously generated passcodes
1087 $todayArrivals = array_map(function($booking) {
1088 // set booking DAC passcodes
1089 $booking['_dac_passcodes_data'] = [];
1090
1091 // check if any passcode was previously created
1092 $previousPasscodes = VikBooking::getBookingHistoryInstance($booking['id'])
1093 ->getEventsWithData(['ND', 'MD'], function($data) {
1094 // cast history data payload to an array
1095 $data = (array) $data;
1096
1097 // ensure the passcode was generated by/for a valid provider, profile and device
1098 return !empty($data['provider']) &&
1099 !empty($data['profile']) &&
1100 !empty($data['device']) &&
1101 (!empty($data['passcode']) || !empty($data['props']));
1102 });
1103
1104 foreach (array_reverse((array) $previousPasscodes) as $previousData) {
1105 // ensure we only have array values
1106 $previousData = (array) json_decode(json_encode($previousData), true);
1107
1108 // push booking passcode data
1109 $passcodeData = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
1110
1111 if ($passcodeData) {
1112 // push booking passcode data
1113 $booking['_dac_passcodes_data'][] = ($previousData['passcode'] ?? '') ?: (array) ($previousData['props'] ?? []);
1114 }
1115 }
1116
1117 // returned the mapped booking array
1118 return $booking;
1119 }, $todayArrivals);
1120
1121 // filter out today bookings that should not be watched or that were watched already
1122 $todayArrivals = array_filter($todayArrivals, function($booking) {
1123 // ensure booking passcodes data is set and no history events are available for "first access"
1124 return !empty($booking['_dac_passcodes_data']) && !VikBooking::getBookingHistoryInstance($booking['id'])->hasEvent('FA');
1125 });
1126
1127 if (!$todayArrivals) {
1128 // nothing to watch for today at this time
1129 return $firstAccessCount;
1130 }
1131
1132 // iterate bookings arriving today
1133 foreach ($todayArrivals as $booking) {
1134 // wrap the booking information into a registry
1135 $registry = VBOBookingRegistry::getInstance($booking);
1136
1137 // get list of booked listing ids and subunits
1138 $bookedListingSubunits = $registry->getBookedListingSubunits();
1139
1140 // scan all the eligible integration records
1141 foreach ($integrations as $integration) {
1142 // set DAC passcodes data within the booking registry
1143 $registry->setDACProperty($integration->getAlias(), 'passcodes_data', $booking['_dac_passcodes_data'] ?? []);
1144
1145 // iterate all provider integration devices
1146 foreach ($integration->getDevices() as $device) {
1147 // get the listing-subunit pairs compatible with the current device
1148 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1149
1150 // iterate all listing units connected to the current device, if any
1151 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1152 // obtain listing ID and subunit number
1153 list($listingId, $subunitId) = $listingSubunitPair;
1154
1155 // set current room index to identify a multi-room booking context
1156 $registry->setCurrentRoomIndex($listingIndex);
1157
1158 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1159 $registry->setCurrentRoomNumber($subunitId);
1160
1161 try {
1162 // attempt to find the first access on this device for the current booking
1163 $result = $integration->detectFirstAccess($device, $listingId, $registry);
1164
1165 // parse the device capability execution result
1166 if ($result) {
1167 // increase counter
1168 $firstAccessCount++;
1169
1170 // store booking history record
1171 VikBooking::getBookingHistoryInstance($registry->getID())
1172 ->setBookingData($registry->getData(), $registry->getRooms())
1173 ->setExtraData([
1174 'provider' => $integration->getProfileProvider(),
1175 'profile' => $integration->getProfileID(),
1176 'device' => $device->getID(),
1177 ])
1178 ->store('FA', sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result));
1179
1180 // store an entry within the notifications center for the successful operation
1181 VBOFactory::getNotificationCenter()
1182 ->store([
1183 [
1184 'sender' => 'dac',
1185 'type' => 'dac.FA.ok',
1186 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1187 'summary' => sprintf('%s: %s', JText::translate('VBOBOOKHISTORYTFA'), strip_tags((string) $result)),
1188 'idorder' => $registry->getID(),
1189 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1190 ],
1191 ]);
1192
1193 // update booking registration status to "checked-in"
1194 (new VBOModelReservation)->updateRegistration(1, $registry->getID());
1195 }
1196 } catch (Exception $e) {
1197 // do nothing
1198 }
1199 }
1200 }
1201 }
1202 }
1203
1204 return $firstAccessCount;
1205 }
1206
1207 /**
1208 * Handles the upcoming check-ins by triggering the operations involving provider devices.
1209 * This method is constantly executed as a cron-schedule by the CMS platform itself.
1210 * Will invoke only the provider integrations that generate passcodes "before the check-in".
1211 *
1212 * @return bool True if some door-access-control actions were performed, false otherwise.
1213 */
1214 public function handleUpcomingArrivals()
1215 {
1216 // count the door access handling actions performed
1217 $doorAccessActions = 0;
1218
1219 // scan the eligible integration records for generating door-access passcodes at the time of booking
1220 foreach ($this->loadGeneratingIntegrations('checkin') as $record) {
1221 // get the integration provider
1222 $integration = $this->getIntegrationProvider($record['provider_alias']);
1223 if (!$integration) {
1224 // unknown integration provider
1225 continue;
1226 }
1227
1228 // inject profile record within the integration provider
1229 $integration->setProfileRecord($record);
1230
1231 // load the upcoming check-ins within the configured profile period, if any
1232 foreach ($this->loadUpcomingReservations($integration->getNextGenerationPeriodTimestamps()) as $booking) {
1233 // wrap the booking information into a registry
1234 $registry = VBOBookingRegistry::getInstance($booking);
1235
1236 // ensure this booking was never processed before
1237 if ($integration->getBookingAccessProcessed($registry->getID())) {
1238 // skip this booking as it was already processed
1239 continue;
1240 }
1241
1242 // flag the booking as processed
1243 $integration->setBookingAccessProcessed($registry->getID());
1244
1245 // get list of booked listing ids and subunits
1246 $bookedListingSubunits = $registry->getBookedListingSubunits();
1247
1248 // iterate all provider integration devices
1249 foreach ($integration->getDevices() as $device) {
1250 // get the listing-subunit pairs compatible with the current device
1251 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1252
1253 // iterate all listing units connected to the current device, if any
1254 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1255 // obtain listing ID and subunit number
1256 list($listingId, $subunitId) = $listingSubunitPair;
1257
1258 // set current room index to identify a multi-room booking context
1259 $registry->setCurrentRoomIndex($listingIndex);
1260
1261 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1262 $registry->setCurrentRoomNumber($subunitId);
1263
1264 // call door access control on provider record for the current device, listing and booking
1265 try {
1266 // set proper history/notification type first
1267 $historyType = 'ND';
1268
1269 // new booking
1270 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1271
1272 // parse the device capability execution result
1273 if ($result) {
1274 // increase counter
1275 $doorAccessActions++;
1276
1277 // store booking history record
1278 VikBooking::getBookingHistoryInstance($registry->getID())
1279 ->setBookingData($registry->getData(), $registry->getRooms())
1280 ->setExtraData([
1281 'provider' => $integration->getProfileProvider(),
1282 'profile' => $integration->getProfileID(),
1283 'device' => $device->getID(),
1284 'passcode' => $result->getPasscode(),
1285 'props' => $result->getProperties(),
1286 ])
1287 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1288
1289 // store an entry within the notifications center for the successful operation
1290 try {
1291 VBOFactory::getNotificationCenter()
1292 ->store([
1293 [
1294 'sender' => 'dac',
1295 'type' => sprintf('dac.%s.ok', $historyType),
1296 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1297 'summary' => strip_tags((string) $result),
1298 'idorder' => $registry->getID(),
1299 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1300 'label' => $integration->getName(),
1301 'widget' => 'door_access_control',
1302 'widget_options' => [
1303 'provider' => $integration->getProfileProvider(),
1304 'profile' => $integration->getProfileID(),
1305 'device' => $device->getID(),
1306 ],
1307 ],
1308 ]);
1309 } catch (Exception $e) {
1310 // do nothing
1311 }
1312 }
1313 } catch (Exception $e) {
1314 // check if the error exception contains retry data
1315 $retryData = [];
1316 if ($e instanceof VBODooraccessException) {
1317 // obtain the retry information
1318 $retryData = [
1319 'callback' => $e->getRetryCallback(),
1320 'options' => $e->getRetryData(),
1321 ];
1322 }
1323
1324 // store an entry within the notifications center for the failed operation
1325 try {
1326 VBOFactory::getNotificationCenter()
1327 ->store([
1328 [
1329 'sender' => 'dac',
1330 'type' => sprintf('dac.%s.nok', $historyType),
1331 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1332 'summary' => $e->getMessage() ?: 'An error occurred.',
1333 'idorder' => $registry->getID(),
1334 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1335 'label' => JText::translate('VBO_TAKE_ACTION'),
1336 'widget' => 'door_access_control',
1337 'widget_options' => [
1338 'provider' => $integration->getProfileProvider(),
1339 'profile' => $integration->getProfileID(),
1340 'device' => $device->getID(),
1341 'retry_data' => $retryData,
1342 ],
1343 ],
1344 ]);
1345 } catch (Exception $e) {
1346 // do nothing
1347 }
1348 }
1349 }
1350 }
1351 }
1352
1353 // update integration record
1354 $this->saveIntegrationRecord($integration, ['data' => $integration->getData()]);
1355 }
1356
1357 return (bool) $doorAccessActions;
1358 }
1359
1360 /**
1361 * Triggers the operations involving provider devices during a new booking confirmation event.
1362 *
1363 * @param array $booking The booking record.
1364 * @param array $booking_rooms The booking room records.
1365 *
1366 * @return bool
1367 */
1368 public function processBookingConfirmation(array $booking, array $booking_rooms = [])
1369 {
1370 // wrap the booking information into a registry
1371 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1372
1373 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1374 // do nothing when we're not dealing with a real confirmed and accepted reservation
1375 return false;
1376 }
1377
1378 // new booking
1379 return $this->handleBookingEvent('confirmation', $registry);
1380 }
1381
1382 /**
1383 * Triggers the operations involving provider devices during a booking modification event.
1384 *
1385 * @param array $booking The booking record.
1386 * @param array $booking_rooms The booking room records.
1387 * @param array $prev_booking The previous booking record.
1388 *
1389 * @return bool
1390 */
1391 public function processBookingModification(array $booking, array $booking_rooms = [], array $prev_booking = [])
1392 {
1393 // wrap the booking information into a registry
1394 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms, $prev_booking);
1395
1396 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1397 // do nothing when we're not dealing with a real confirmed and accepted reservation
1398 return false;
1399 }
1400
1401 // detect changes from previous to current booking even at room-level (subunits)
1402 if (!$registry->detectAlterations($roomLevel = true)) {
1403 // do nothing when no significant changes were made to the booking
1404 return false;
1405 }
1406
1407 // modified booking
1408 return $this->handleBookingEvent('modification', $registry);
1409 }
1410
1411 /**
1412 * Triggers the operations involving provider devices during a booking cancellation event.
1413 *
1414 * @param array $booking The booking record.
1415 * @param array $booking_rooms The booking room records.
1416 *
1417 * @return bool
1418 */
1419 public function processBookingCancellation(array $booking, array $booking_rooms = [])
1420 {
1421 // wrap the booking information into a registry
1422 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1423
1424 if ($registry->isClosure() || (!$registry->isCancelled() && !$registry->getPrevious())) {
1425 // do nothing when we're not dealing with a real booking cancellation or modification
1426 return false;
1427 }
1428
1429 // cancelled booking
1430 return $this->handleBookingEvent('cancellation', $registry);
1431 }
1432
1433 /**
1434 * Triggers the operations involving provider devices during a pre-checkin completed event.
1435 *
1436 * @param array $booking The booking record.
1437 * @param array $booking_rooms The booking room records.
1438 *
1439 * @return bool
1440 *
1441 * @since 1.18.6 (J) - 1.8.6 (WP)
1442 */
1443 public function processPrecheckinCompleted(array $booking, array $booking_rooms = [])
1444 {
1445 // wrap the booking information into a registry
1446 $registry = VBOBookingRegistry::getInstance($booking, $booking_rooms);
1447
1448 if ($registry->isClosure() || $registry->isOverbooking() || !$registry->isConfirmed()) {
1449 // do nothing when we're not dealing with a real confirmed and accepted reservation
1450 return false;
1451 }
1452
1453 // ensure this is the first time the pre-checkin details are submitted, and not simply updated
1454 $totalPrecheckins = VikBooking::getBookingHistoryInstance($registry->getID())->getEventsWithData(['PC']);
1455 if ($totalPrecheckins && count($totalPrecheckins) > 1) {
1456 // this booking already got the pre-checkin information submitted once
1457 return false;
1458 }
1459
1460 // handle the pre-checkin completed event
1461 return $this->handleBookingEvent('precheckin', $registry);
1462 }
1463
1464 /**
1465 * Spawns the callback by reaching the OAuth2 authorization link (callback URL).
1466 *
1467 * @param ?array $data Optional spawn data to parse.
1468 *
1469 * @return void
1470 *
1471 * @throws Exception
1472 *
1473 * @since 1.18.6 (J) - 1.8.6 (WP)
1474 */
1475 public function spawnOAuthCallback(?array $data = null)
1476 {
1477 $app = JFactory::getApplication();
1478
1479 // gather request or data variables to load the proper integration
1480 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1481 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1482
1483 if (!$provider) {
1484 throw new Exception('Missing DAC integration provider to spawn.', 400);
1485 }
1486
1487 if (!$profileId) {
1488 throw new Exception('Missing DAC integration profile to spawn.', 400);
1489 }
1490
1491 // get the requested integration provider
1492 $integration = $this->getIntegrationProvider($provider);
1493
1494 if (!$integration) {
1495 throw new Exception('Invalid door access control provider identifier.', 404);
1496 }
1497
1498 // load the requested integration profile
1499 $profile = $this->loadIntegrationRecord((int) $profileId);
1500
1501 if (!$profile) {
1502 throw new Exception('Invalid door access control provider profile.', 404);
1503 }
1504
1505 // inject profile record within the integration
1506 $integration->setProfileRecord($profile);
1507
1508 // let the integration spawn the OAuth authorization callback to perform its actions
1509 $integration->spawnOAuthCallback($data);
1510 }
1511
1512 /**
1513 * Spawns the Webhook endpoint URL callback.
1514 *
1515 * @param ?array $data Optional spawn data to parse.
1516 *
1517 * @return void
1518 *
1519 * @throws Exception
1520 *
1521 * @since 1.18.6 (J) - 1.8.6 (WP)
1522 */
1523 public function spawnWebhookCallback(?array $data = null)
1524 {
1525 $app = JFactory::getApplication();
1526
1527 // gather request or data variables to load the proper integration
1528 $provider = ($data['provider'] ?? '') ?: $app->input->getString('provider', '');
1529 $profileId = ($data['profile'] ?? 0) ?: $app->input->getUInt('profile', 0);
1530
1531 if (!$provider) {
1532 throw new Exception('Missing DAC integration provider to spawn.', 400);
1533 }
1534
1535 if (!$profileId) {
1536 throw new Exception('Missing DAC integration profile to spawn.', 400);
1537 }
1538
1539 // get the requested integration provider
1540 $integration = $this->getIntegrationProvider($provider);
1541
1542 if (!$integration) {
1543 throw new Exception('Invalid door access control provider identifier.', 404);
1544 }
1545
1546 // load the requested integration profile
1547 $profile = $this->loadIntegrationRecord((int) $profileId);
1548
1549 if (!$profile) {
1550 throw new Exception('Invalid door access control provider profile.', 404);
1551 }
1552
1553 // inject profile record within the integration
1554 $integration->setProfileRecord($profile);
1555
1556 // let the integration spawn the Webhook endpoint URL callback to perform its actions
1557 $integration->spawnWebhookCallback($data);
1558 }
1559
1560 /**
1561 * Handles a booking event by triggering the operations involving provider devices.
1562 * Will invoke only the provider integrations that can generate passcodes either
1563 * "at the time of booking" or "upon pre-checkin completed".
1564 *
1565 * @param string $type The booking event type (confirmation, modification, cancellation, precheckin).
1566 * @param VBOBookingRegistry $registry The booking registry containing all room related details.
1567 *
1568 * @return bool True if some door-access-control actions were performed, false otherwise.
1569 *
1570 * @since 1.18.6 (J) - 1.8.6 (WP) added support to pre-checkin event.
1571 */
1572 protected function handleBookingEvent(string $type, VBOBookingRegistry $registry)
1573 {
1574 // list of supported booking event types
1575 $validTypes = [
1576 'confirmation',
1577 'modification',
1578 'cancellation',
1579 'precheckin',
1580 ];
1581
1582 if (!in_array($type, $validTypes)) {
1583 // unsupported booking event
1584 return false;
1585 }
1586
1587 // determine the allowed generation type(s) for the integrations
1588 if ($type === 'confirmation') {
1589 // only integrations that generate passcodes "at the time of booking" should run
1590 $generationTypes = ['booking'];
1591 } elseif ($type === 'precheckin') {
1592 // only integrations that generate passcodes "upon pre-checkin completed" should run
1593 $generationTypes = ['precheckin'];
1594 } else {
1595 // both "at the time of booking" and "upon pre-checkin completed" integrations should run
1596 $generationTypes = ['booking', 'precheckin'];
1597 }
1598
1599 // count the door access handling actions performed
1600 $doorAccessActions = 0;
1601
1602 // get list of booked listing ids and subunits
1603 $bookedListingSubunits = $registry->getBookedListingSubunits();
1604
1605 // check if the booking was only modified at room-level (subunits)
1606 $modifiedRoomLevelOnly = false;
1607 if ($type === 'modification' && !$registry->detectAlterations($roomLevel = false)) {
1608 // turn flag on, because no booking-level changes were detected, only room-level changes (subunits)
1609 $modifiedRoomLevelOnly = true;
1610 }
1611
1612 // scan the eligible integrations for generating passcodes at the time of booking or pre-checkin
1613 foreach ($this->loadGeneratingIntegrations($generationTypes) as $record) {
1614 // get the integration provider
1615 $integration = $this->getIntegrationProvider($record['provider_alias']);
1616 if (!$integration) {
1617 // unknown integration provider
1618 continue;
1619 }
1620
1621 // inject profile record within the integration provider
1622 $integration->setProfileRecord($record);
1623
1624 // ensure a booking modification event will not generate a new passcode too early
1625 if (($record['gentype'] ?? '') === 'precheckin' && in_array($type, ['modification', 'cancellation']) && !$registry->hasPreCheckedIn()) {
1626 // avoid premature actions because the guest has not gone through pre-checkin yet
1627 continue;
1628 }
1629
1630 // iterate all provider integration devices
1631 foreach ($integration->getDevices() as $device) {
1632 if ($modifiedRoomLevelOnly && !$device->getConnectedSubunits()) {
1633 // prevent useless passcode modifications in case of room-level only changes and no subunits mapped
1634 continue;
1635 }
1636
1637 // get the listing-subunit pairs compatible with the current device
1638 $deviceListingUnits = $device->intersectListingUnits($bookedListingSubunits);
1639
1640 // iterate all listing units connected to the current device, if any
1641 foreach ($deviceListingUnits as $listingIndex => $listingSubunitPair) {
1642 // obtain listing ID and subunit number
1643 list($listingId, $subunitId) = $listingSubunitPair;
1644
1645 // set current room index to identify a multi-room booking context
1646 $registry->setCurrentRoomIndex($listingIndex);
1647
1648 // set current room number (1-based index) to identify an exact subunit for hotels inventory (if any)
1649 $registry->setCurrentRoomNumber($subunitId);
1650
1651 // call door access control on provider record for the current device, listing and booking
1652 try {
1653 if ($type === 'modification') {
1654 // set proper history/notification type first
1655 $historyType = 'MD';
1656 // booking modified
1657 $result = $integration->modifyBookingDoorAccess($device, $listingId, $registry);
1658 } elseif ($type === 'cancellation') {
1659 // set proper history/notification type first
1660 $historyType = 'CD';
1661 // booking cancelled
1662 $result = $integration->cancelBookingDoorAccess($device, $listingId, $registry);
1663 } else {
1664 // new booking or pre-checkin completed event
1665 // set proper history/notification type first
1666 $historyType = 'ND';
1667 // new booking
1668 $result = $integration->createBookingDoorAccess($device, $listingId, $registry);
1669 }
1670
1671 // parse the device capability execution result
1672 if ($result) {
1673 // increase counter
1674 $doorAccessActions++;
1675
1676 // store booking history record
1677 VikBooking::getBookingHistoryInstance($registry->getID())
1678 ->setBookingData($registry->getData(), $registry->getRooms())
1679 ->setExtraData([
1680 'provider' => $integration->getProfileProvider(),
1681 'profile' => $integration->getProfileID(),
1682 'device' => $device->getID(),
1683 'passcode' => $result->getPasscode(),
1684 'props' => $result->getProperties(),
1685 ])
1686 ->store($historyType, sprintf('%s - %s: %s', (string) $integration->getProfileName(), (string) $device->getName(), (string) $result->getPasscode()));
1687
1688 // store an entry within the notifications center for the successful operation
1689 try {
1690 VBOFactory::getNotificationCenter()
1691 ->store([
1692 [
1693 'sender' => 'dac',
1694 'type' => sprintf('dac.%s.ok', $historyType),
1695 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1696 'summary' => strip_tags((string) $result),
1697 'idorder' => $registry->getID(),
1698 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1699 'label' => $integration->getName(),
1700 'widget' => 'door_access_control',
1701 'widget_options' => [
1702 'provider' => $integration->getProfileProvider(),
1703 'profile' => $integration->getProfileID(),
1704 'device' => $device->getID(),
1705 ],
1706 ],
1707 ]);
1708 } catch (Exception $e) {
1709 // do nothing
1710 }
1711 }
1712 } catch (Exception $e) {
1713 // check if the error exception contains retry data
1714 $retryData = [];
1715 if ($e instanceof VBODooraccessException) {
1716 // obtain the retry information
1717 $retryData = [
1718 'callback' => $e->getRetryCallback(),
1719 'options' => $e->getRetryData(),
1720 ];
1721 }
1722
1723 // store an entry within the notifications center for the failed operation
1724 try {
1725 VBOFactory::getNotificationCenter()
1726 ->store([
1727 [
1728 'sender' => 'dac',
1729 'type' => sprintf('dac.%s.nok', $historyType),
1730 'title' => sprintf('%s - %s', (string) $integration->getProfileName(), (string) $device->getName()),
1731 'summary' => $e->getMessage() ?: 'An error occurred.',
1732 'idorder' => $registry->getID(),
1733 'avatar' => preg_match('/^http/', (string) $integration->getIcon()) ? $integration->getIcon() : null,
1734 'label' => JText::translate('VBO_TAKE_ACTION'),
1735 'widget' => 'door_access_control',
1736 'widget_options' => [
1737 'provider' => $integration->getProfileProvider(),
1738 'profile' => $integration->getProfileID(),
1739 'device' => $device->getID(),
1740 'retry_data' => $retryData,
1741 ],
1742 ],
1743 ]);
1744 } catch (Exception $e) {
1745 // do nothing
1746 }
1747 }
1748 }
1749 }
1750 }
1751
1752 return (bool) $doorAccessActions;
1753 }
1754
1755 /**
1756 * Loads the upcoming reservations within the given check-in timestamp intervals.
1757 *
1758 * @param array $intervals List of two timestamps for the check-in bounds.
1759 *
1760 * @return array List of eligible reservation records, if any.
1761 */
1762 protected function loadUpcomingReservations(array $intervals)
1763 {
1764 $dbo = JFactory::getDbo();
1765
1766 $dbo->setQuery(
1767 $dbo->getQuery(true)
1768 ->select('*')
1769 ->from($dbo->qn('#__vikbooking_orders'))
1770 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1771 ->where($dbo->qn('closure') . ' = 0')
1772 ->where('(' . $dbo->qn('checkin') . ' BETWEEN ' . ((int) ($intervals[0] ?? time())) . ' AND ' . ((int) ($intervals[1] ?? strtotime('+1 hour'))) . ')')
1773 );
1774
1775 return $dbo->loadAssocList();
1776 }
1777
1778 /**
1779 * Loads the checked-out reservations within the given check-in timestamp intervals.
1780 *
1781 * @param array $intervals List of two timestamps for the check-out bounds.
1782 *
1783 * @return array List of eligible reservation records, if any.
1784 *
1785 * @since 1.18.7 (J) - 1.8.7 (WP)
1786 */
1787 protected function loadCheckedOutReservations(array $intervals)
1788 {
1789 $dbo = JFactory::getDbo();
1790
1791 $dbo->setQuery(
1792 $dbo->getQuery(true)
1793 ->select('*')
1794 ->from($dbo->qn('#__vikbooking_orders'))
1795 ->where($dbo->qn('status') . ' = ' . $dbo->q('confirmed'))
1796 ->where($dbo->qn('closure') . ' = 0')
1797 ->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')))) . ')')
1798 );
1799
1800 return $dbo->loadAssocList();
1801 }
1802
1803 /**
1804 * Decodes and unserializes the proper record columns.
1805 *
1806 * @param array $record The raw integration database record.
1807 *
1808 * @return array The decoded and unserialized record columns.
1809 */
1810 protected function decodeIntegrationRecord(array $record)
1811 {
1812 if (!empty($record['settings']) && is_scalar($record['settings'])) {
1813 $record['settings'] = (array) json_decode($record['settings'], true);
1814 }
1815
1816 if (!empty($record['devices']) && is_scalar($record['devices'])) {
1817 $record['devices'] = (array) unserialize($record['devices']);
1818 }
1819
1820 if (!empty($record['data']) && is_scalar($record['data'])) {
1821 $record['data'] = (array) json_decode($record['data'], true);
1822 }
1823
1824 return $record;
1825 }
1826
1827 /**
1828 * Loads the available door access integrations.
1829 *
1830 * @return void
1831 */
1832 protected function loadIntegrations()
1833 {
1834 // access the platform dispatcher
1835 $dispatcher = VBOFactory::getPlatform()->getDispatcher();
1836
1837 // integrations path and files
1838 $integrations_base = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'provider' . DIRECTORY_SEPARATOR;
1839 $integration_files = glob($integrations_base . '*.php');
1840 $integrations_banned = [];
1841
1842 /**
1843 * Trigger event to let other plugins register additional integrations.
1844 *
1845 * @return array A list of supported integrations.
1846 */
1847 $list = $dispatcher->filter('onLoadDoorAccessIntegrations');
1848
1849 foreach ($list as $chunk) {
1850 // merge default integration files with the returned ones
1851 $integration_files = array_merge($integration_files, (array) $chunk);
1852 }
1853
1854 /**
1855 * Trigger event to let other plugins unregister specific integrations.
1856 *
1857 * @return array A list of integration identifiers (aliases) to unload.
1858 */
1859 $unloaded = $dispatcher->filter('onUnloadDoorAccessIntegrations');
1860
1861 foreach ($unloaded as $chunk) {
1862 // merge all the the returned ones
1863 $integrations_banned = array_merge($integrations_banned, (array) $chunk);
1864 }
1865
1866 // scan the integration files and register the installed integrations
1867 foreach ($integration_files as $integration_file) {
1868 try {
1869 // require the file if it exists
1870 if (is_file($integration_file)) {
1871 require_once($integration_file);
1872 }
1873
1874 // integration identifier (alias)
1875 $integration_alias = basename($integration_file, '.php');
1876
1877 // check if the integration was unloaded
1878 if (in_array($integration_alias, $integrations_banned)) {
1879 continue;
1880 }
1881
1882 // build integration class name
1883 $classname = 'VBODooraccessProvider' . str_replace(' ', '', ucwords(str_replace('_', ' ', $integration_alias)));
1884
1885 if (class_exists($classname)) {
1886 // instantiate integration object
1887 $integration = new $classname;
1888
1889 // push the installed integration
1890 $this->integrations[] = $integration;
1891 }
1892 } catch (Throwable $e) {
1893 // do nothing but skip the current integration
1894 }
1895 }
1896
1897 /**
1898 * Sort the integrations by name and by offline status.
1899 *
1900 * @since 1.18.15 (J) - 1.8.15 (WP)
1901 */
1902 usort($this->integrations, function($a, $b) {
1903 if (($a->isOffline ?? null) && !($b->isOffline ?? null)) {
1904 return 1;
1905 }
1906
1907 if (($b->isOffline ?? null) && !($a->isOffline ?? null)) {
1908 return -1;
1909 }
1910
1911 return strcasecmp($a->getShortName(), $b->getShortName());
1912 });
1913 }
1914 }
1915