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

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

742 lines 20.2 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 Alessio Gaggii - E4J s.r.l.
6 * @copyright Copyright (C) 2024 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 * Notification Center class handler.
16 *
17 * @since 1.16.8 (J) - 1.6.8 (WP)
18 */
19 final class VBONotificationCenter
20 {
21 /** @var array */
22 private static $count_cache = [];
23
24 /** @var bool */
25 private static $lang_loaded = false;
26
27 /** @var int */
28 private $last_found_notifications = 0;
29
30 /**
31 * Class constructor.
32 */
33 public function __construct()
34 {}
35
36 /**
37 * Returns a list of notification groups.
38 *
39 * @param bool $global whether the "All" global group should be included.
40 *
41 * @return array
42 */
43 public function getGroups($global = true)
44 {
45 $dbo = JFactory::getDbo();
46
47 $groups = [];
48
49 $dbo->setQuery(
50 $dbo->getQuery(true)
51 ->select($dbo->qn('group'))
52 ->select('SUM((' . $dbo->qn('read') . ' + 1) % 2) AS ' . $dbo->qn('badge_count'))
53 ->from($dbo->qn('#__vikbooking_notifications'))
54 ->group($dbo->qn('group'))
55 ->order($dbo->qn('badge_count') . ' DESC')
56 );
57
58 foreach ($dbo->loadAssocList() as $group) {
59 $name_key = 'VBO_NOTIFS_GROUP_' . strtoupper($group['group']);
60 $name_lang = JText::translate($name_key);
61 $group_name = $name_key != $name_lang ? $name_lang : ucwords(str_replace('_', ' ', strtolower($group['group'])));
62 $groups[] = [
63 'id' => $group['group'],
64 'name' => $group_name,
65 'badge_count' => $group['badge_count'],
66 ];
67 }
68
69 if ($global) {
70 // prepend the "global" notifications group
71 array_unshift($groups, [
72 'id' => '',
73 'name' => JText::translate('VBNEWCOUPONEIGHT'),
74 'badge_count' => array_sum(array_column($groups, 'badge_count')),
75 ]);
76 }
77
78 return $groups;
79 }
80
81 /**
82 * In order to avoid displaying a badge of "99+" unread notifications,
83 * we mark as read those notifications older than 14 days to give more
84 * dynamism to the whole Notifications Center and related badge counters.
85 *
86 * @param int $age Age of notifications expressed in days.
87 *
88 * @return void
89 *
90 * @since 1.16.9 (J) - 1.6.9 (WP)
91 */
92 public function readOldNotifications($age = 14)
93 {
94 $session = JFactory::getSession();
95
96 if ($session->get('vbo_nc_readold')) {
97 // check was made already
98 return;
99 }
100
101 // turn session flag on
102 $session->set('vbo_nc_readold', 1);
103
104 // ensure age is a valid value in days
105 $age = abs((int) $age);
106 $age = $age ?: 14;
107
108 // build "old" date limit
109 $date_limit = JFactory::getDate('now')->modify("-{$age} days")->toSql();
110
111 $dbo = JFactory::getDbo();
112
113 $dbo->setQuery(
114 $dbo->getQuery(true)
115 ->update($dbo->qn('#__vikbooking_notifications'))
116 ->set($dbo->qn('read') . ' = 1')
117 ->where($dbo->qn('createdon') . ' < ' . $dbo->q($date_limit))
118 );
119
120 $dbo->execute();
121 }
122
123 /**
124 * Counts the number of unread notifications.
125 *
126 * @param string $group optional group identifier.
127 *
128 * @return int
129 */
130 public function countUnread(string $group = '')
131 {
132 $group_key = $group ?: 0;
133
134 if (isset(static::$count_cache[$group_key])) {
135 return static::$count_cache[$group_key];
136 }
137
138 $dbo = JFactory::getDbo();
139
140 $q = $dbo->getQuery(true)
141 ->select('COUNT(*)')
142 ->from($dbo->qn('#__vikbooking_notifications'))
143 ->where($dbo->qn('read') . ' = 0');
144
145 if ($group) {
146 $q->where($dbo->qn('group') . ' = ' . $dbo->q($group));
147 }
148
149 $dbo->setQuery($q);
150 $tot_unread = (int) $dbo->loadResult();
151
152 static::$count_cache[$group_key] = $tot_unread;
153
154 return static::$count_cache[$group_key];
155 }
156
157 /**
158 * Loads a list of notifications.
159 *
160 * @param int $start query limit start.
161 * @param int $lim query limit count.
162 * @param array $filters optional list of column filters.
163 * @param int $min_id optional minimum notification ID.
164 */
165 public function loadNotifications(int $start = 0, int $lim = 0, array $filters = [], int $min_id = 0)
166 {
167 $dbo = JFactory::getDbo();
168
169 $q = $dbo->getQuery(true)
170 ->select('SQL_CALC_FOUND_ROWS *')
171 ->from($dbo->qn('#__vikbooking_notifications'));
172
173 foreach ($filters as $column => $filter) {
174 if (is_scalar($filter)) {
175 $q->where($dbo->qn($column) . ' = ' . $dbo->q($filter));
176 } else {
177 foreach ($filter as $filter_data) {
178 if (!is_array($filter_data) || !isset($filter_data['operand']) || !isset($filter_data['value'])) {
179 continue;
180 }
181 $q->where($dbo->qn($column) . ' ' . $filter_data['operand'] . ' ' . $dbo->q($filter_data['value']));
182 }
183 }
184 }
185
186 if ($min_id && empty($filters['id'])) {
187 $q->where($dbo->qn('id') . ' > ' . $min_id);
188 }
189
190 $q->order($dbo->qn('createdon') . ' DESC');
191 $q->order($dbo->qn('read') . ' ASC');
192
193 $dbo->setQuery($q, $start, $lim);
194 $notifications = $dbo->loadObjectList();
195
196 // count the total number of found rows
197 $this->last_found_notifications = 0;
198 if ($notifications) {
199 $dbo->setQuery(
200 $dbo->getQuery(true)
201 ->select('FOUND_ROWS()')
202 );
203 $this->last_found_notifications = (int) $dbo->loadResult();
204 }
205
206 // grab the non-empty booking IDs from the list of objects
207 $booking_ids = array_filter(array_column($notifications, 'idorder'));
208
209 // load the customer names and profile pictures for all the involved booking IDs
210 $customer_infos = [];
211 if ($booking_ids) {
212 $booking_ids = array_unique(array_map(function($id) {
213 return (int) $id;
214 }, $booking_ids));
215
216 $dbo->setQuery(
217 $dbo->getQuery(true)
218 ->select([
219 $dbo->qn('co.idorder'),
220 $dbo->qn('c.first_name'),
221 $dbo->qn('c.last_name'),
222 $dbo->qn('c.pic'),
223 ])
224 ->from($dbo->qn('#__vikbooking_customers_orders', 'co'))
225 ->leftJoin($dbo->qn('#__vikbooking_customers', 'c') . ' ON ' . $dbo->qn('co.idcustomer') . ' = ' . $dbo->qn('c.id'))
226 ->where($dbo->qn('co.idorder') . ' IN (' . implode(', ', $booking_ids) . ')')
227 );
228
229 $customer_infos = $dbo->loadObjectList();
230 }
231
232 // append the customer information properties to each notification and adjust properties
233 foreach ($notifications as &$notification) {
234 // check for call-to-action data
235 if ($notification->cta_data) {
236 $cta_data = json_decode($notification->cta_data);
237 if (is_object($cta_data)) {
238 // overwrite property with the decoded payload
239 $notification->cta_data = $cta_data;
240 }
241 }
242
243 // set default null values
244 $notification->customer_name = null;
245 $notification->customer_pic = null;
246
247 // parse all customer information objects
248 foreach ($customer_infos as $customer_info) {
249 if (!$notification->idorder || $customer_info->idorder != $notification->idorder) {
250 // not the booking ID we want to look for
251 continue;
252 }
253
254 if (!empty($customer_info->first_name) || !empty($customer_info->last_name)) {
255 // populate customer name
256 $notification->customer_name = trim($customer_info->first_name . ' ' . $customer_info->last_name);
257 }
258
259 if (!empty($customer_info->pic)) {
260 // populate customer profile picture
261 $notification->customer_pic = $customer_info->pic;
262 }
263
264 // go to the next notification
265 break;
266 }
267 }
268
269 // unset last reference
270 unset($notification);
271
272 // return the list of notification objects
273 return $notifications;
274 }
275
276 /**
277 * Counts the last found notification rows.
278 *
279 * @return int
280 *
281 * @see loadNotifications()
282 */
283 public function countFoundNotifications()
284 {
285 return $this->last_found_notifications;
286 }
287
288 /**
289 * Reads some or all notifications (if none given), and returns
290 * a list of groups involved for the given notification IDs.
291 *
292 * @param array $notification_ids list of IDs or empty list.
293 *
294 * @return array list of group identifiers involved.
295 */
296 public function readNotifications(array $notification_ids)
297 {
298 $dbo = JFactory::getDbo();
299
300 // sanitize the list
301 $notification_ids = array_filter(array_map('intval', $notification_ids));
302
303 // read the requested notifications
304 $q = $dbo->getQuery(true)
305 ->update($dbo->qn('#__vikbooking_notifications'))
306 ->set($dbo->qn('read') . ' = 1');
307
308 if ($notification_ids) {
309 $q->where($dbo->qn('id') . ' IN (' . implode(', ', $notification_ids) . ')');
310 }
311
312 $dbo->setQuery($q);
313 $dbo->execute();
314
315 // get the groups involved
316 $q = $dbo->getQuery(true)
317 ->select($dbo->qn('group'))
318 ->from($dbo->qn('#__vikbooking_notifications'))
319 ->group($dbo->qn('group'));
320
321 if ($notification_ids) {
322 $q->where($dbo->qn('id') . ' IN (' . implode(', ', $notification_ids) . ')');
323 }
324
325 $dbo->setQuery($q);
326
327 // build a list of groups involved (if any notification is saved, hence was updated)
328 $involved = [];
329 foreach ($dbo->loadObjectList() as $record) {
330 $involved[] = $record->group;
331 }
332
333 if ($involved) {
334 // prepend the "global" notifications group as an empty value
335 array_unshift($involved, 0);
336 }
337
338 return $involved;
339 }
340
341 /**
342 * Reads the notifications matching the given criterias. Useful for reading
343 * the notifications when opening a specific context, like a chat thread.
344 *
345 * @param array $criteria associative list of column-value criterias.
346 *
347 * @return int list of group identifiers involved.
348 */
349 public function readMatchingNotifications(array $criteria)
350 {
351 if (!$criteria) {
352 // do not proceed
353 return 0;
354 }
355
356 $dbo = JFactory::getDbo();
357
358 // start building the query for reading the matching notifications
359 $q = $dbo->getQuery(true)
360 ->update($dbo->qn('#__vikbooking_notifications'))
361 ->set($dbo->qn('read') . ' = 1');
362
363 foreach ($criteria as $col => $value) {
364 $q->where($dbo->qn($col) . ' = ' . $dbo->q($value));
365 }
366
367 try {
368 $dbo->setQuery($q);
369 $dbo->execute();
370
371 return (int) $dbo->getAffectedRows();
372 } catch (Exception $e) {
373 // silently catch any database error
374 }
375
376 return 0;
377 }
378
379 /**
380 * Processes a list of notification objects to store.
381 *
382 * @param array $notifications list of notification objects.
383 *
384 * @return array associative list of data stored.
385 *
386 * @throws Exception
387 */
388 public function store(array $notifications)
389 {
390 $result = [
391 'new_notifications' => 0,
392 ];
393
394 foreach ($notifications as $notification) {
395 // wrap the notification array/object into a proper type-registry
396 $notif_registry = VBONotificationElements::getInstance($notification);
397
398 if ($this->storeNotification($notif_registry)) {
399 // record was stored successfully
400 $result['new_notifications']++;
401
402 /**
403 * Trigger a postflight action for this type of notification.
404 *
405 * @since 1.18.3 (J) - 1.8.3 (WP)
406 */
407 $notif_registry->postflight();
408
409 // parse the next one
410 continue;
411 }
412
413 if ($notif_registry->getError()) {
414 // abort only in case of error
415 throw new Exception($notif_registry->getError(), $notif_registry->getErrorCode());
416 }
417 }
418
419 return $result;
420 }
421
422 /**
423 * Parses and stores a notification registry, if compliant.
424 *
425 * @param VBONotificationElements $notification the notification registry.
426 *
427 * @return bool
428 */
429 private function storeNotification(VBONotificationElements $notification)
430 {
431 $dbo = JFactory::getDbo();
432
433 if (!$notification->get('sender') || !$notification->get('type')) {
434 $notification->setError('Notification sender and type are mandatory');
435 return false;
436 }
437
438 if (!$notification->get('title') && !$notification->get('summary')) {
439 $notification->setError('Either title or summary is mandatory');
440 return false;
441 }
442
443 if (strcasecmp($notification->get('sender', ''), 'website')) {
444 // ensure language definitions are loaded for non-website notifications
445 $this->loadLanguageDefs();
446 }
447
448 // build notification record to store or update
449 $record = new stdClass;
450
451 // ensure the same notification does not exist already
452 if ($duplicate_id = $this->signatureExists($notification->getSignature())) {
453 // update the date for the existing notification ID
454 $record->id = $duplicate_id;
455 $record->createdon = JFactory::getDate('now')->toSql();
456 $dbo->updateObject('#__vikbooking_notifications', $record, 'id');
457
458 // abort without raising any errors to skip a duplicate notification from being created
459 return false;
460 }
461
462 // set record properties for creation
463 $record->signature = $notification->getSignature();
464 $record->group = $notification->getGroup();
465 $record->type = $notification->getType();
466 $record->title = $notification->getTitle();
467 $record->summary = $notification->getSummary();
468 $record->avatar = $notification->getAvatar();
469 $record->cta_data = $notification->getCallToActionData();
470 $record->idorder = $notification->getReservationID();
471 $record->idorderota = $notification->getOTAReservationID();
472 $record->channel = $notification->getChannel();
473 $record->createdon = $notification->getDate();
474
475 if (!$dbo->insertObject('#__vikbooking_notifications', $record, 'id')) {
476 $notification->setError('Query failed when inserting the record');
477 return false;
478 }
479
480 return true;
481 }
482
483 /**
484 * Parses a new guest message received through the Channel Manager from an OTA.
485 *
486 * @param object $thread the thread record in VCM.
487 * @param object $message the message record in VCM.
488 *
489 * @return array associative list of data stored.
490 *
491 * @throws Exception
492 */
493 public function parseNewGuestMessage($thread, $message)
494 {
495 if (!is_object($thread) || !is_object($message)) {
496 throw new Exception('Invalid message object provided', 400);
497 }
498
499 // ensure language definitions are loaded
500 $this->loadLanguageDefs();
501
502 // build guest message notification payload
503 $notification = [
504 'sender' => 'guests',
505 'type' => 'guest_message',
506 'title' => JText::sprintf('VBO_MESSAGE_FROM', $message->sender_name ?? 'guest'),
507 'summary' => $message->content ?? '',
508 'avatar' => $message->avatar ?? null,
509 'idorder' => $thread->idorder ?? null,
510 'idorderota' => $thread->idorderota ?? null,
511 'channel' => $thread->channel ?? null,
512 ];
513
514 // store the guest message notification(s)
515 return $this->store([$notification]);
516 }
517
518 /**
519 * Checks if new notifications should be downloaded or generated, at most once per day.
520 * The method is usually invoked through the administrator section of VikBooking.
521 *
522 * @return void
523 */
524 public function downloadNotifications()
525 {
526 $session = JFactory::getSession();
527
528 if ($session->get('vbo_nc_download')) {
529 // check was made already
530 return;
531 }
532
533 // turn session flag on
534 $session->set('vbo_nc_download', 1);
535
536 $config = VBOFactory::getConfig();
537
538 $last_download = $config->get('nc_last_download', '');
539 $today_dt = date('Y-m-d');
540
541 if ($last_download == $today_dt) {
542 // check was made already
543 return;
544 }
545
546 // turn db flag on
547 $config->set('nc_last_download', $today_dt);
548
549 // check if automatic notifications should be generated.
550 if (!empty($last_download)) {
551 // past month notification
552 $last_info = getdate(strtotime($last_download));
553 $prev_m_info = getdate(strtotime('-1 month'));
554 if ($last_info['mon'] == $prev_m_info['mon'] && $last_info['year'] == $prev_m_info['year']) {
555 // we are on a new month, generate a finance notification for the past month
556 $this->generatePastMonthNotification($last_info);
557 }
558
559 /**
560 * Check if there are some pending AI training drafts to review.
561 *
562 * @since 1.18.6 (J) - 1.8.6 (WP)
563 */
564 try {
565 if (!class_exists('VCMAiModelTraining')) {
566 // do not proceed
567 throw new Exception('E4jConnect Channel Manager unavailable or unsupported.', 500);
568 }
569 // load the draft training sets
570 $response = (new VCMAiModelTraining)->getItems([
571 // needs review
572 'status' => 2,
573 ], [
574 'ordering' => 'created',
575 'direction' => 'asc',
576 'offset' => 0,
577 'limit' => 1,
578 ]);
579
580 if ($response->pagination->total ?? 0) {
581 // there are some pending drafts to review, generate a notification
582 $this->generatePendingAiDraftsNotification((int) $response->pagination->total);
583 }
584 } catch (Exception $e) {
585 // do nothing
586 }
587 }
588
589 // check if the Channel Manager can download new notifications
590
591 if (!is_file(VCM_SITE_PATH . DIRECTORY_SEPARATOR . 'helpers' . DIRECTORY_SEPARATOR . 'lib.vikchannelmanager.php')) {
592 // channel manager not installed
593 return;
594 }
595
596 if (!class_exists('VCMNotificationsHelper')) {
597 // channel manager is outdated
598 return;
599 }
600
601 // let the CM request to download new notifications, if any eligible channel is configured
602 try {
603 (new VCMNotificationsHelper)
604 ->downloadNotifications();
605 } catch (Throwable $e) {
606 // silently catch the error
607 }
608 }
609
610 /**
611 * Checks if a notification exists from the given signature.
612 *
613 * @param string $signature The notification elements signature.
614 *
615 * @return int|null Either the existing record ID or null.
616 */
617 private function signatureExists(string $signature)
618 {
619 $dbo = JFactory::getDbo();
620
621 $dbo->setQuery(
622 $dbo->getQuery(true)
623 ->select($dbo->qn('id'))
624 ->from($dbo->qn('#__vikbooking_notifications'))
625 ->where($dbo->qn('signature') . ' = ' . $dbo->q($signature))
626 );
627
628 return $dbo->loadResult();
629 }
630
631 /**
632 * Generates a financial notification for the stats of the past month.
633 *
634 * @param array $month the past month date information.
635 *
636 * @return void
637 */
638 private function generatePastMonthNotification(array $month)
639 {
640 $months_map = [
641 JText::translate('VBMONTHONE'),
642 JText::translate('VBMONTHTWO'),
643 JText::translate('VBMONTHTHREE'),
644 JText::translate('VBMONTHFOUR'),
645 JText::translate('VBMONTHFIVE'),
646 JText::translate('VBMONTHSIX'),
647 JText::translate('VBMONTHSEVEN'),
648 JText::translate('VBMONTHEIGHT'),
649 JText::translate('VBMONTHNINE'),
650 JText::translate('VBMONTHTEN'),
651 JText::translate('VBMONTHELEVEN'),
652 JText::translate('VBMONTHTWELVE'),
653 ];
654
655 // build past month finance notification payload
656 $notification = [
657 'sender' => 'website',
658 'type' => 'info',
659 'title' => $months_map[$month['mon'] - 1] . ' ' . $month['year'],
660 'summary' => JText::translate('VBO_CHECK_HOW_WENT'),
661 'label' => JText::translate('VBO_W_FINANCE_TITLE'),
662 'widget' => 'finance',
663 'widget_options' => [
664 'fromdate' => date('Y-m-01', $month[0]),
665 'todate' => date('Y-m-t', $month[0]),
666 'type' => 'month',
667 ],
668 ];
669
670 try {
671 // store the notification(s)
672 $this->store([$notification]);
673 } catch (Throwable $e) {
674 // silently catch the error
675 }
676 }
677
678 /**
679 * Generates a notification for the pending AI training drafts to review.
680 *
681 * @param int $count The number of pending AI drafts to review.
682 *
683 * @return void
684 */
685 private function generatePendingAiDraftsNotification(int $count)
686 {
687 // build past month finance notification payload
688 $notification = [
689 'sender' => 'ai',
690 'type' => 'training.drafts',
691 'title' => JText::translate('VBO_W_AITRAININGDRAFTS_TITLE'),
692 'summary' => JText::plural('VBO_AITRAININGDRAFTS_SUMMARY_COUNT', $count),
693 'widget' => 'ai_training_drafts',
694 'widget_options' => [
695 'trigger' => 'nc',
696 'dt' => date('Y-m-d'),
697 ],
698 ];
699
700 try {
701 // store the notification(s)
702 $this->store([$notification]);
703 } catch (Throwable $e) {
704 // silently catch the error
705 }
706 }
707
708 /**
709 * Attempts to load the language definitions in case the client/CMS requires so.
710 *
711 * @return void
712 */
713 private function loadLanguageDefs()
714 {
715 if (static::$lang_loaded) {
716 return;
717 }
718
719 // turn flag on
720 static::$lang_loaded = true;
721
722 $lang = JFactory::getLanguage();
723
724 if (VBOPlatformDetection::isJoomla()) {
725 $lang->load('com_vikbooking', JPATH_ADMINISTRATOR, $lang->getTag(), true);
726 $lang->load('joomla', JPATH_ADMINISTRATOR, $lang->getTag(), true);
727 } else {
728 $lang->load('com_vikbooking', VIKBOOKING_LANG, $lang->getTag(), true);
729
730 // make sure to register the language handler
731 $lib_base_path = defined('VIKBOOKING_LIBRARIES') ? VIKBOOKING_LIBRARIES : '';
732 if (!$lib_base_path && defined('VIKCHANNELMANAGER_LIBRARIES')) {
733 $lib_base_path = str_replace('vikchannelmanager' . DIRECTORY_SEPARATOR . 'libraries', 'vikbooking' . DIRECTORY_SEPARATOR . 'libraries', VIKCHANNELMANAGER_LIBRARIES);
734 }
735
736 if ($lib_base_path) {
737 $lang->attachHandler($lib_base_path . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR . 'admin.php', 'vikbooking');
738 }
739 }
740 }
741 }
742