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 / task / model / task.php

task.php in VikBooking Hotel Booking Engine & PMS 1.8.16, at admin/helpers/src/task/model/task.php

936 lines 34.3 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 * Task model task implementation.
16 *
17 * @since 1.18.0 (J) - 1.8.0 (WP)
18 */
19 final class VBOTaskModelTask
20 {
21 /**
22 * Proxy for immediately accessing the object.
23 *
24 * @return VBOTaskModelTask
25 */
26 public static function getInstance()
27 {
28 return new static;
29 }
30
31 /**
32 * Class constructor.
33 */
34 public function __construct()
35 {}
36
37 /**
38 * Item loading implementation.
39 *
40 * @param mixed $pk An optional primary key value to load the row by,
41 * or an associative array of fields to match.
42 *
43 * @return object|null The record object on success, null otherwise.
44 */
45 public function getItem($pk)
46 {
47 $dbo = JFactory::getDbo();
48
49 $q = $dbo->getQuery(true)
50 ->select('*')
51 ->from($dbo->qn('#__vikbooking_tm_tasks'));
52
53 if (is_array($pk)) {
54 foreach ($pk as $column => $value) {
55 $q->where($dbo->qn($column) . ' = ' . $dbo->q($value));
56 }
57 } else {
58 $q->where($dbo->qn('id') . ' = ' . (int) $pk);
59 }
60
61 $dbo->setQuery($q, 0, 1);
62
63 return $dbo->loadObject();
64 }
65
66 /**
67 * Items loading implementation.
68 *
69 * @param array $clauses List of associative columns to filter
70 * (column => [operator, value])
71 * @param int $start Query limit start.
72 * @param int $lim Query limit value.
73 * @param array $cols Optional list of columns to fetch.
74 *
75 * @return array|int List of record objects, or integer if counting columns.
76 */
77 public function getItems(array $clauses = [], $start = 0, $lim = 0, array $cols = [])
78 {
79 $app = JFactory::getApplication();
80 $dbo = JFactory::getDbo();
81
82 // tell whether we are actually counting the items
83 $counting = $cols && substr($cols[0] ?? '', 0, 5) === 'COUNT' && !$start && $lim === 1;
84
85 // start query object
86 $q = $dbo->getQuery(true);
87
88 if (!$cols) {
89 $q->select($dbo->qn('t') . '.*');
90 } else {
91 $q->select(array_map(function($column) use ($dbo) {
92 if (preg_match('/^[A-Z]/', $column)) {
93 // no quoting needed when column name starts with an upper case letter (i.e "COUNT(*)")
94 return $column;
95 }
96 if (!preg_match('/^t\./', $column)) {
97 $column = 't.' . $column;
98 }
99 return $dbo->qn($column);
100 }, $cols));
101 }
102
103 $q->from($dbo->qn('#__vikbooking_tm_tasks', 't'));
104
105 if (($clauses['assignee'] ?? null) || ($clauses['assignees'] ?? null) || ($clauses['operator'] ?? null)) {
106 $q->leftJoin($dbo->qn('#__vikbooking_tm_task_assignees', 'ta') . ' ON ' . $dbo->qn('ta.id_task') . ' = ' . $dbo->qn('t.id'));
107 }
108
109 if (is_array($clauses['fulltext'] ?? null) && is_string($clauses['fulltext']['value'] ?? null)) {
110 // full-text special clause to search over task titles and notes
111 // select full-text match score (relevance)
112 $q->select('MATCH(' . $dbo->qn('title') . ', ' . $dbo->qn('notes') . ') AGAINST(' . $dbo->q($clauses['fulltext']['value']) . ') AS ' . $dbo->qn('relevance'));
113 // add where statement to only include matches
114 $q->where('MATCH(' . $dbo->qn('title') . ', ' . $dbo->qn('notes') . ') AGAINST(' . $dbo->q($clauses['fulltext']['value']) . ') > 0');
115 // add order by match relevance
116 $q->order($dbo->qn('relevance') . ' DESC');
117 // unset this special clause
118 unset($clauses['fulltext']);
119 }
120
121 foreach ($clauses as $column => $data) {
122 if (!is_array($data) || !array_key_exists('value', $data)) {
123 // null values are also accepted for "value"
124 continue;
125 }
126
127 if (in_array($column, ['assignee', 'assignees', 'operator'])) {
128 $column = 'ta.id_operator';
129 } elseif (!preg_match('/^t\./', $column)) {
130 $column = 't.' . $column;
131 }
132
133 if (is_array($data['value'])) {
134 if (preg_match('/[a-z]/i', ($data['value'][0] ?? '0'))) {
135 // use "IN" for a list of quoted strings
136 $q->where($dbo->qn($column) . ' IN (' . implode(', ', array_map([$dbo, 'q'], $data['value'])) . ')');
137 } else {
138 // default to "IN" for a list of integers
139 $q->where($dbo->qn($column) . ' IN (' . implode(', ', array_map('intval', $data['value'])) . ')');
140 }
141 } else {
142 // singular fetching value
143 if (is_null($data['value'])) {
144 // look for a null (or not null) value
145 $q->where($dbo->qn($column) . ' IS' . (($data['operator'] ?? '=') == '!=' ? ' NOT' : '') . ' NULL');
146 } else {
147 // look for a real value
148 if ($data['instruction'] ?? null) {
149 // raw clause instruction given
150 $q->where($data['instruction']);
151 } else {
152 // match value
153 $q->where($dbo->qn($column) . ' ' . ($data['operator'] ?? '=') . ' ' . $dbo->q($data['value']));
154 }
155 }
156 }
157 }
158
159 if (!isset($clauses['dueon'])) {
160 // default ordering is by current date to list the upcoming tasks
161 $q->order('IF(' . $dbo->qn('t.dueon') . ' >= ' . $dbo->q(JFactory::getDate('now', $app->get('offset'))->toSql()) . ', 1, 0)' . ' DESC');
162 }
163 $q->order($dbo->qn('t.dueon') . ' ASC');
164 $q->order($dbo->qn('t.id') . ' ASC');
165
166 $dbo->setQuery($q, $start, $lim);
167
168 if ($counting) {
169 // count items
170 return $dbo->loadResult();
171 }
172
173 // fetch items
174 $tasks = $dbo->loadObjectList();
175
176 try {
177 // take the latest 20 unread threads
178 $threads = VBOFactory::getChatMediator()->getMessages(
179 (new VBOChatSearch)
180 ->aggregate()
181 ->unread()
182 ->limit(20)
183 );
184 } catch (Exception $e) {
185 // silently catch any possible authentication error
186 $threads = [];
187 }
188
189 $threadsLookup = [];
190
191 // map threads by context ID
192 foreach ($threads as $message) {
193 $threadsLookup[$message->getContext()->getID()] = $message;
194 }
195
196 // check whether the loaded tasks have at least an unread message
197 foreach ($tasks as $task) {
198 $task->hasUnreadMessages = (bool) ($threadsLookup[$task->id] ?? null);
199 }
200
201 return $tasks;
202 }
203
204 /**
205 * Item IDs loading implementation.
206 *
207 * @param array $clauses List of associative columns to filter
208 * (column => [operator, value])
209 * @param int $start Query limit start.
210 * @param int $lim Query limit value.
211 *
212 * @return array List of record objects.
213 */
214 public function getItemIds(array $clauses = [], $start = 0, $lim = 0)
215 {
216 return $this->getItems($clauses, $start, $lim, ['id']);
217 }
218
219 /**
220 * Items loading through filtering implementation.
221 *
222 * @param array $filters Associative list filters to apply.
223 * @param int $start Query limit start.
224 * @param int $lim Query limit value.
225 * @param bool $count True for counting rather than fetching.
226 *
227 * @return array|int List of record objects, or integer if counting columns.
228 */
229 public function filterItems(array $filters, $start = 0, $lim = 0, bool $count = false)
230 {
231 $app = JFactory::getApplication();
232 $dbo = JFactory::getDbo();
233
234 // filter out empty filters
235 $filters = array_filter($filters);
236
237 // build fetching clauses by normalizing filter names
238 $clauses = [];
239
240 if ($filters['id_area'] ?? null) {
241 // filter by area/project ID
242 $clauses['id_area'] = [
243 'value' => (int) $filters['id_area'],
244 ];
245 } elseif (is_array($filters['id_areas'] ?? null)) {
246 // filter by area/project IDs
247 $clauses['id_area'] = [
248 'value' => array_map('intval', $filters['id_areas']),
249 ];
250 }
251
252 if ($filters['statusId'] ?? null) {
253 // filter by status(es)
254 $clauses['status_enum'] = [
255 'value' => $filters['statusId'],
256 ];
257 } else {
258 // status not specified, ignore archived tasks by default
259 $clauses['archived'] = [
260 'value' => 0,
261 ];
262 }
263
264 if ($filters['tag'] ?? null) {
265 // filter by tag requires multiple conditions
266 $qpieces = [
267 $dbo->qn('t.tags') . ' = ' . $dbo->q('[' . $filters['tag'] . ']'),
268 $dbo->qn('t.tags') . ' LIKE ' . $dbo->q('[' . $filters['tag'] . ',%'),
269 $dbo->qn('t.tags') . ' LIKE ' . $dbo->q('%,' . $filters['tag'] . ']'),
270 $dbo->qn('t.tags') . ' LIKE ' . $dbo->q('%,' . $filters['tag'] . ',%'),
271 ];
272
273 $clauses['tags'] = [
274 'instruction' => '(' . implode(' OR ', $qpieces) . ')',
275 'value' => (int) $filters['tag'],
276 ];
277 }
278
279 if ($filters['assignee'] ?? null) {
280 // filter by a single assignee ID or null (-1) for tasks not assigned to any operator
281 $clauses['assignee'] = [
282 'value' => $filters['assignee'] == -1 ? null : intval($filters['assignee']),
283 ];
284 } elseif (is_array($filters['assignees'] ?? null)) {
285 // filter by assignee IDs
286 $clauses['assignees'] = [
287 'value' => $filters['assignees'],
288 ];
289 }
290
291 if (is_numeric($filters['operator'] ?? null)) {
292 // unlike the "assignee(s)" filter, this filter will get the tasks assigned
293 // to the given operator ID, OR, those who are not yet assigned to any operator
294 // by excluding the tasks that belong to private areas
295
296 // cast filter to integer
297 $filters['operator'] = (int) $filters['operator'];
298
299 // build SQL instruction for the operator assignments
300 $instructions = [
301 $dbo->qn('ta.id_operator') . ' = ' . $filters['operator'],
302 $dbo->qn('ta.id_operator') . ' IS NULL',
303 ];
304 if (is_array($filters['id_rooms'] ?? null) && ($id_rooms = array_filter($filters['id_rooms']))) {
305 // operators should be able to accept only the unassigned tasks that belong to their listings
306 $instructions[1] .= ' AND ' . $dbo->qn('t.id_room') . ' IN (' . implode(', ', array_map('intval', $id_rooms)) . ')';
307 // unset the "id_rooms" filter as it was already applied
308 unset($filters['id_rooms']);
309 }
310 $instruction = '(' . implode(' OR ', array_map(function($q) {
311 return '(' . $q . ')';
312 }, $instructions)) . ')';
313
314 // get a list of private area IDs, if any
315 $privateAreaIds = VBOFactory::getTaskManager()->getPrivateAreas();
316
317 // prepend SQL instruction to exclude the private areas
318 if ($privateAreaIds) {
319 $instruction = '(' . $dbo->qn('t.id_area') . ' NOT IN (' . implode(', ', $privateAreaIds) . ') AND ' . $instruction . ')';
320 }
321
322 // set final filter
323 $clauses['operator'] = [
324 'instruction' => $instruction,
325 'value' => $filters['operator'],
326 ];
327 }
328
329 if (is_numeric($filters['id_room'] ?? null)) {
330 // cast filter to integer
331 $filters['id_room'] = (int) $filters['id_room'];
332
333 // filter by room ID or category ID
334 if ($filters['id_room'] > 0) {
335 // room ID given
336 $clauses['id_room'] = [
337 'value' => $filters['id_room'],
338 ];
339 } else {
340 // category ID given
341 $room_ids = VikBooking::getAvailabilityInstance(true)->filterRoomCategories((array) $filters['id_room']);
342 if ($room_ids) {
343 // filter by multiple room IDs
344 $clauses['id_room'] = [
345 'value' => $room_ids,
346 ];
347 }
348 }
349 } elseif (is_array($filters['id_rooms'] ?? null) && ($id_rooms = array_filter($filters['id_rooms']))) {
350 // filter by multiple room IDs
351 $clauses['id_room'] = [
352 'value' => array_values($id_rooms),
353 ];
354 }
355
356 if ($filters['id_order'] ?? null) {
357 // filter by booking ID
358 $clauses['id_order'] = [
359 'value' => (int) $filters['id_order'],
360 ];
361 } elseif ($filters['with_order'] ?? null) {
362 // filter by tasks assigned to a booking ID (NOT NULL)
363 $clauses['id_order'] = [
364 'operator' => '!=',
365 'value' => null,
366 ];
367 }
368
369 if ($filters['dates'] ?? null) {
370 // filter by date(s) by converting the local date-time to UTC
371 list($fromDt, $toDt) = $this->getFilterDatesInterval((string) $filters['dates'], $local = false, $sql = true);
372
373 if ($fromDt) {
374 // build SQL instruction
375 $instruction = $dbo->qn('t.dueon') . ' BETWEEN ' . $dbo->q($fromDt) . ' AND ' . $dbo->q($toDt);
376
377 // check if the same dates filter should be applied on the begin date
378 if ($filters['calendar'] ?? false) {
379 // modify SQL instruction to include the begin date and the finish date
380 $instructions = [
381 $instruction,
382 $dbo->qn('t.beganon') . ' BETWEEN ' . $dbo->q($fromDt) . ' AND ' . $dbo->q($toDt),
383 $dbo->qn('t.finishedon') . ' IS NOT NULL AND (' . $dbo->q($fromDt) . ' BETWEEN IFNULL(' . $dbo->qn('t.beganon') . ', ' . $dbo->qn('t.dueon') . ') AND ' . $dbo->qn('t.finishedon') . ')',
384 ];
385 $instruction = '(' . implode(' OR ', array_map(function($q) {
386 return '(' . $q . ')';
387 }, $instructions)) . ')';
388 }
389
390 // add clause
391 $clauses['dueon'] = [
392 'instruction' => $instruction,
393 'value' => $filters['dates'],
394 ];
395 }
396 }
397
398 if ($filters['future'] ?? null) {
399 // filter by due date in the future
400 $today_midnight = JFactory::getDate('now', $app->get('offset'))->modify('00:00:00')->toSql();
401 $clauses['dueon'] = [
402 'instruction' => $dbo->qn('t.dueon') . ' >= ' . $dbo->q($today_midnight),
403 'value' => $today_midnight,
404 ];
405 }
406
407 if ($filters['search'] ?? null) {
408 if (preg_match('/^id:\s?[0-9]+$/i', $filters['search'])) {
409 // search task by ID
410 $clauses['id'] = [
411 'value' => (int) preg_replace('/[^0-9]/', '', $filters['search']),
412 ];
413 } else {
414 // full-text tasks search by title and notes
415 $clauses['fulltext'] = [
416 'value' => $filters['search'],
417 ];
418 }
419 }
420
421 if ($count) {
422 // count items
423 return $this->getItems($clauses, 0, 1, ['COUNT(*)']);
424 }
425
426 // fetch items
427 return $this->getItems($clauses, $start, $lim);
428 }
429
430 /**
431 * Stores a new task record.
432 *
433 * @param array|object $record The record to store.
434 *
435 * @return int|null The new record ID or null.
436 */
437 public function save($record)
438 {
439 $app = JFactory::getApplication();
440 $dbo = JFactory::getDbo();
441
442 $taskManager = VBOFactory::getTaskManager();
443
444 $record = (object) $record;
445
446 // normalize received notes HTML; if any
447 $this->normalizeNotesHtml($record);
448
449 if (is_array(($record->tags ?? null))) {
450 // parse all tags, even custom ones, into a list of IDs
451 $record->tags = json_encode(VBOTaskModelColortag::getInstance()->parseIds($record->tags));
452 }
453
454 if (empty($record->status_enum) && !empty($record->id_area)) {
455 // fallback to the first area status enumeration found
456 $statuses = $taskManager->getStatusGroupElements(VBOTaskArea::getRecordInstance($record->id_area)->getStatuses(), $flatten = true);
457 $record->status_enum = $statuses[0]['id'];
458 }
459
460 // check due date
461 if (empty($record->dueon)) {
462 // default to current date-time because the due date cannot be empty
463 $record->dueon = JFactory::getDate('now', $app->get('offset'))->toSql();
464 } else {
465 // convert the given (and expected) local date-time to UTC
466 $record->dueon = JFactory::getDate($record->dueon, $app->get('offset'))->toSql();
467 }
468
469 // force creation date
470 $record->createdon = JFactory::getDate('now', $app->get('offset'))->toSql();
471
472 // always attempt to get and unset the assignee IDs as they do not belong to the task record
473 $assigneesList = $record->assignees ?? [];
474 unset($record->assignees);
475
476 /**
477 * Trigger event to allow third-party plugins to manipulate the task payload before it gets saved
478 */
479 VBOFactory::getPlatform()->getDispatcher()->trigger('onBeforeSaveTaskManagerTask', [$record, $isNewTask = true]);
480
481 // store task record
482 $dbo->insertObject('#__vikbooking_tm_tasks', $record, 'id');
483
484 /**
485 * Trigger event to allow third-party plugins to operate once the task record has been saved
486 */
487 VBOFactory::getPlatform()->getDispatcher()->trigger('onAfterSaveTaskManagerTask', [$record, $isNewTask = true]);
488
489 $taskId = ($record->id ?? null) ?: null;
490
491 if ($taskId && $assigneesList) {
492 $assignees = array_filter(array_map('intval', (array) $assigneesList));
493 foreach ($assignees as $assigneeId) {
494 $relRecord = [
495 'id_task' => $taskId,
496 'id_operator' => $assigneeId,
497 ];
498 $relRecord = (object) $relRecord;
499 $dbo->insertObject('#__vikbooking_tm_task_assignees', $relRecord, 'id');
500 }
501 }
502
503 // track the task creation
504 (new VBOTaskHistoryTracker(
505 new VBOHistoryModelDatabase(
506 new VBOTaskHistoryContext($record->id)
507 )
508 ))->track(null, $record);
509
510 // make sure the new task exists
511 if ($taskManager->statusTypeExists($record->status_enum)) {
512 // execute the extra rules that the new status should apply
513 $taskManager->getStatusTypeInstance($record->status_enum)->apply((int) $record->id);
514 }
515
516 return $taskId;
517 }
518
519 /**
520 * Updates an existing task record.
521 *
522 * @param array|object $record The record details to update.
523 *
524 * @return bool
525 */
526 public function update($record)
527 {
528 $app = JFactory::getApplication();
529 $dbo = JFactory::getDbo();
530
531 $record = (object) $record;
532
533 if (empty($record->id)) {
534 return false;
535 }
536
537 // get previous item
538 $prev = $this->getItem($record->id);
539
540 if (!$prev) {
541 throw new UnexpectedValueException('The task [' . $record->id . '] you are trying to update does not exist.', 404);
542 }
543
544 // normalize received notes HTML; if any
545 $this->normalizeNotesHtml($record);
546
547 if (is_array(($record->tags ?? null))) {
548 // parse all tags, even custom ones, into a list of IDs
549 $record->tags = json_encode(VBOTaskModelColortag::getInstance()->parseIds($record->tags));
550 }
551
552 // check due date
553 if (!empty($record->dueon)) {
554 // convert the given (and expected) local date-time to UTC
555 $record->dueon = JFactory::getDate($record->dueon, $app->get('offset'))->toSql();
556 } else {
557 // prevent the system from saving NULL dates
558 unset($record->dueon);
559 }
560
561 // check begin date
562 if (!empty($record->beganon)) {
563 // convert the given (and expected) local date-time to UTC
564 $record->beganon = JFactory::getDate($record->beganon, $app->get('offset'))->toSql();
565 } else {
566 // prevent the system from saving NULL dates
567 unset($record->beganon);
568 }
569
570 // check finish date
571 if (!empty($record->finishedon)) {
572 // convert the given (and expected) local date-time to UTC
573 $record->finishedon = JFactory::getDate($record->finishedon, $app->get('offset'))->toSql();
574 } else {
575 // prevent the system from saving NULL dates
576 unset($record->finishedon);
577 }
578
579 // always unset the creation date-time and force the modification date-time
580 unset($record->createdon);
581 $record->modifiedon = JFactory::getDate('now', $app->get('offset'))->toSql();
582
583 // always attempt to get and unset the assignee IDs as they do not belong to the task record
584 $assigneesList = $record->assignees ?? null;
585 unset($record->assignees);
586
587 /**
588 * Trigger event to allow third-party plugins to manipulate the task payload before it gets saved
589 */
590 VBOFactory::getPlatform()->getDispatcher()->trigger('onBeforeSaveTaskManagerTask', [$record, $isNewTask = false]);
591
592 // update task record
593 $updated = (bool) $dbo->updateObject('#__vikbooking_tm_tasks', $record, 'id');
594
595 // inject assignees again
596 $record->assignees = $assigneesList;
597
598 /**
599 * Trigger event to allow third-party plugins to operate once the task record has been saved
600 */
601 VBOFactory::getPlatform()->getDispatcher()->trigger('onAfterSaveTaskManagerTask', [$record, $isNewTask = false]);
602
603 if ($assigneesList !== null) {
604 // sanitize the assignees list
605 $assigneesList = array_filter(array_map('intval', (array) $assigneesList));
606
607 if ($assigneesList) {
608 // update task-operator relations
609
610 // get the current task-operator relations
611 $dbo->setQuery(
612 $dbo->getQuery(true)
613 ->select($dbo->qn('id_operator'))
614 ->from($dbo->qn('#__vikbooking_tm_task_assignees'))
615 ->where($dbo->qn('id_task') . ' = ' . (int) $record->id)
616 );
617
618 $prev->assignees = array_filter(array_map('intval', $dbo->loadColumn()));
619
620 // find the relations to eventually add or delete
621 $addingOperators = array_diff($assigneesList, $prev->assignees);
622 $missingOperators = array_diff($prev->assignees, $assigneesList);
623
624 foreach ($missingOperators as $operatorId) {
625 // delete task-operator relation
626 $dbo->setQuery(
627 $dbo->getQuery(true)
628 ->delete($dbo->qn('#__vikbooking_tm_task_assignees'))
629 ->where($dbo->qn('id_task') . ' = ' . (int) $record->id)
630 ->where($dbo->qn('id_operator') . ' = ' . (int) $operatorId)
631 );
632 $dbo->execute();
633 }
634
635 foreach ($addingOperators as $operatorId) {
636 // add task-operator relation
637 $relRecord = [
638 'id_task' => (int) $record->id,
639 'id_operator' => (int) $operatorId,
640 ];
641 $relRecord = (object) $relRecord;
642 $dbo->insertObject('#__vikbooking_tm_task_assignees', $relRecord, 'id');
643 }
644 } else {
645 // delete all previous task-operator relations, if any
646 $dbo->setQuery(
647 $dbo->getQuery(true)
648 ->delete($dbo->qn('#__vikbooking_tm_task_assignees'))
649 ->where($dbo->qn('id_task') . ' = ' . (int) $record->id)
650 );
651 $dbo->execute();
652 }
653 }
654
655 // track any changes
656 (new VBOTaskHistoryTracker(
657 new VBOHistoryModelDatabase(
658 new VBOTaskHistoryContext($record->id)
659 )
660 ))->track($prev, $record);
661
662 // check whether the status has changed
663 if ((new VBOTaskHistoryDetectorStatus)->hasChanged((object) $prev, (object) $record)) {
664 $taskManager = VBOFactory::getTaskManager();
665
666 // make sure the new task exists
667 if ($taskManager->statusTypeExists($record->status_enum)) {
668 // execute the extra rules that the new status should apply
669 $taskManager->getStatusTypeInstance($record->status_enum)->apply((int) $record->id);
670 }
671 }
672
673 return $updated;
674 }
675
676 /**
677 * Deletes a task record.
678 *
679 * @param array|int $id The record(s) to delete.
680 *
681 * @return bool
682 */
683 public function delete($id)
684 {
685 $dbo = JFactory::getDbo();
686
687 if (!is_array($id)) {
688 $id = (array) $id;
689 }
690
691 $id = array_map('intval', $id);
692
693 if (!$id) {
694 return false;
695 }
696
697 $dbo->setQuery(
698 $dbo->getQuery(true)
699 ->delete($dbo->qn('#__vikbooking_tm_tasks'))
700 ->where($dbo->qn('id') . ' IN (' . implode(', ', $id) . ')')
701 );
702
703 $dbo->execute();
704 $result = (bool) $dbo->getAffectedRows();
705
706 if ($result) {
707 // delete the task-operator relations
708 $dbo->setQuery(
709 $dbo->getQuery(true)
710 ->delete($dbo->qn('#__vikbooking_tm_task_assignees'))
711 ->where($dbo->qn('id_task') . ' IN (' . implode(', ', $id) . ')')
712 );
713 $dbo->execute();
714 }
715
716 return $result;
717 }
718
719 /**
720 * Given a dates filter identifier, returns the interval of dates in "Y-m-d H:i:s" or "SQL" format.
721 *
722 * @param string $dates The dates filter identifier.
723 * @param bool $local Whether to obtain dates in local or UTC timezone.
724 * @param bool $sql Whether to obtain dates in "SQL" or "Y-m-d H:i:s" format.
725 *
726 * @return array List of dates, from-to, for the interval, or array of null values.
727 */
728 public function getFilterDatesInterval(string $dates, bool $local = true, bool $sql = true)
729 {
730 $useTz = $local ? date_default_timezone_get() : JFactory::getApplication()->get('offset');
731
732 if (!strcasecmp($dates, 'today')) {
733 // filter by today's date
734 $fromDt = JFactory::getDate(date('Y-m-d 00:00:00'), $useTz);
735 $toDt = JFactory::getDate(date('Y-m-d 23:59:59'), $useTz);
736 if ($sql) {
737 return [
738 $fromDt->toSql(),
739 $toDt->toSql(),
740 ];
741 }
742 return [
743 $fromDt->format('Y-m-d H:i:s'),
744 $toDt->format('Y-m-d H:i:s'),
745 ];
746 }
747
748 if (!strcasecmp($dates, 'tomorrow')) {
749 // filter by tomorrow's date
750 $tomorrowTs = strtotime('+1 day');
751 $fromDt = JFactory::getDate(date('Y-m-d 00:00:00', $tomorrowTs), $useTz);
752 $toDt = JFactory::getDate(date('Y-m-d 23:59:59', $tomorrowTs), $useTz);
753 if ($sql) {
754 return [
755 $fromDt->toSql(),
756 $toDt->toSql(),
757 ];
758 }
759 return [
760 $fromDt->format('Y-m-d H:i:s'),
761 $toDt->format('Y-m-d H:i:s'),
762 ];
763 }
764
765 if (!strcasecmp($dates, 'yesterday')) {
766 // filter by yesterday's date
767 $yesterdayTs = strtotime('-1 day');
768 $fromDt = JFactory::getDate(date('Y-m-d 00:00:00', $yesterdayTs), $useTz);
769 $toDt = JFactory::getDate(date('Y-m-d 23:59:59', $yesterdayTs), $useTz);
770 if ($sql) {
771 return [
772 $fromDt->toSql(),
773 $toDt->toSql(),
774 ];
775 }
776 return [
777 $fromDt->format('Y-m-d H:i:s'),
778 $toDt->format('Y-m-d H:i:s'),
779 ];
780 }
781
782 if (!strcasecmp($dates, 'week')) {
783 // filter by this week's date
784 $fromDt = JFactory::getDate(date('Y-m-d 00:00:00'), $useTz);
785 $toDt = JFactory::getDate(date('Y-m-d 23:59:59', strtotime('+1 week')), $useTz);
786 if ($sql) {
787 return [
788 $fromDt->toSql(),
789 $toDt->toSql(),
790 ];
791 }
792 return [
793 $fromDt->format('Y-m-d H:i:s'),
794 $toDt->format('Y-m-d H:i:s'),
795 ];
796 }
797
798 if (!strcasecmp($dates, 'month')) {
799 // filter by this month's date
800 $fromDt = JFactory::getDate(date('Y-m-01 00:00:00'), $useTz);
801 $toDt = JFactory::getDate(date('Y-m-t 23:59:59'), $useTz);
802 if ($sql) {
803 return [
804 $fromDt->toSql(),
805 $toDt->toSql(),
806 ];
807 }
808 return [
809 $fromDt->format('Y-m-d H:i:s'),
810 $toDt->format('Y-m-d H:i:s'),
811 ];
812 }
813
814 if (preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}\s?:\s?[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $dates)) {
815 // filter by custom range of dates
816 $parts = explode(':', $dates);
817 $fromDt = JFactory::getDate(date('Y-m-d 00:00:00', strtotime(trim($parts[0]))), $useTz);
818 $toDt = JFactory::getDate(date('Y-m-d 23:59:59', strtotime(trim($parts[1]))), $useTz);
819 if ($sql) {
820 return [
821 $fromDt->toSql(),
822 $toDt->toSql(),
823 ];
824 }
825 return [
826 $fromDt->format('Y-m-d H:i:s'),
827 $toDt->format('Y-m-d H:i:s'),
828 ];
829 }
830
831 // unrecognized dates filter
832 return [null, null];
833 }
834
835 /**
836 * Updates a checklist element of a specific task.
837 *
838 * @param int $taskId The ID of the task to update.
839 * @param int $n The N-th checkbox to update.
840 * @param bool|null $status The status to assign. Null to toggle the current status.
841 *
842 * @return void
843 */
844 public function updateChecklist(int $taskId, int $n, ?bool $status = null)
845 {
846 // get updated item
847 $task = $this->getItem($taskId);
848
849 if (!$task) {
850 throw new UnexpectedValueException('The task [' . $taskId . '] you are trying to update does not exist.', 404);
851 }
852
853 $index = 0;
854
855 // scan the notes HTML in search of the element to update
856 $task->notes = preg_replace_callback(
857 // take all the ULs holding the data-checked attribute
858 "/<ul[^>]+data-checked=\"(true|false)\"[^>]*>(.*?)<\/ul>/s", function($matches) use ($n, &$index, $status) {
859 // make sure the matches the requested index
860 if (++$index === $n) {
861 if ($status === null) {
862 // toggle the current status
863 $status = $matches[1] !== 'true';
864 }
865
866 // replace the current status with the new one
867 $matches[0] = preg_replace("/data-checked=\"(true|false)\"/", 'data-checked="' . ($status ? 'true' : 'false') . '"', $matches[0]);
868 }
869
870 return $matches[0];
871 },
872 $task->notes
873 );
874
875 // finally update the task
876 $this->update([
877 'id' => $task->id,
878 'notes' => $task->notes,
879 ]);
880 }
881
882 /**
883 * Normalizes the HTML content generated by the preferred WYSIWYG editor.
884 *
885 * @param object $record
886 *
887 * @return void
888 */
889 private function normalizeNotesHtml(object $task) {
890 if (empty($task->notes)) {
891 // task missing, nothing to normalize
892 return;
893 }
894
895 /**
896 * Quill editor supports the checklist feature. However, instead of having the checked status
897 * on the LIs, Quill groups the elements per status under the same UL. Therefore we need to
898 * refactor the following structure:
899 *
900 * ```html
901 * <ul data-checked="true"><li>a</li><li>b</li></ul>
902 * <ul data-checked="false"><li>c</li></ul>
903 * <ul data-checked="true"><li>d</li></ul>
904 * ```
905 *
906 * into this one:
907 *
908 * ```html
909 * <ul data-checked="true"><li>a</li></ul>
910 * <ul data-checked="true"><li>b</li></ul>
911 * <ul data-checked="false"><li>c</li></ul>
912 * <ul data-checked="true"><li>d</li></ul>
913 * ```
914 */
915 $task->notes = preg_replace_callback(
916 // take all the ULs holding the data-checked attribute
917 "/<ul[^>]+data-checked=\"(true|false)\"[^>]*>(.*?)<\/ul>\s*/s",
918 function($ulMatches) {
919 // extract the current status and all the LIs
920 $checked = $ulMatches[1];
921 $lis = $ulMatches[2];
922
923 // wrap all the LIs into different ULs
924 return preg_replace_callback(
925 "/\s*<li[^>]*>(.*?)<\/li>\s*/s",
926 function($liMatches) use ($checked) {
927 return '<ul data-checked="' . $checked . '"><li>' . $liMatches[1] . '</li></ul>';
928 },
929 $lis
930 );
931 },
932 $task->notes
933 );
934 }
935 }
936