PluginProbe ʕ •ᴥ•ʔ
VikAppointments Services Booking Calendar / 1.2.21
VikAppointments Services Booking Calendar v1.2.21
1.2.21 1.2.20 trunk 1.2.17 1.2.18 1.2.19
vikappointments / site / helpers / libraries / ical / importer.php
vikappointments / site / helpers / libraries / ical Last commit date
parser 3 days ago reader 3 days ago event.php 3 days ago importer.php 3 days ago index.html 3 days ago parser.php 3 days ago reader.php 3 days ago
importer.php
620 lines
1 <?php
2 /**
3 * @package VikAppointments
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2021 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 * Imports an array of iCalendar events.
16 *
17 * @since 1.7.3
18 */
19 class VAPIcalImporter extends JObject
20 {
21 /**
22 * The employee ID. If omitted, the system will attempt
23 * to extract it from the calendar event by looking into
24 * the "organizer" attribute.
25 *
26 * @var int
27 */
28 protected $employee;
29
30 /**
31 * The service ID. If omitted, the system will attempt
32 * to extract it from the calendar by checking whether
33 * there's a service that matches the event summary.
34 *
35 * @var int
36 */
37 protected $service;
38
39 /**
40 * Whether the system should validate the availability of
41 * the imported events or whether it should import them
42 * in any case.
43 *
44 * @var bool
45 * @since 1.7.5
46 */
47 protected $validateAvailability;
48
49 /**
50 * A unique identifier used to create an assignment between
51 * the imported events and the source calendar.
52 *
53 * @var string|null
54 * @since 1.7.5
55 */
56 protected $calendarHash;
57
58 /**
59 * Class constructor.
60 *
61 * @param array $options An array of options.
62 */
63 public function __construct(array $options = [])
64 {
65 $this->employee = (int) ($options['id_employee'] ?? 0);
66 $this->service = (int) ($options['id_service'] ?? 0);
67
68 $this->validateAvailability = (bool) ($options['validate_availability'] ?? true);
69
70 $this->calendarHash = $options['calendar_uid'] ?? null;
71 }
72
73 /**
74 * Attempts to import a list of iCalendar events.
75 *
76 * @param VAPIcalEvent[] $events The events to import.
77 *
78 * @return VAPIcalEvent[] A list of imported events.
79 */
80 public function import(array $events)
81 {
82 $added = [];
83
84 // iterate all events
85 foreach ($events as $event)
86 {
87 if (!$event instanceof VAPIcalEvent)
88 {
89 // ignore invalid instances
90 continue;
91 }
92
93 try
94 {
95 // try to import the event
96 $appData = $this->importEvent($event);
97
98 if ($appData)
99 {
100 $appData['summary'] = $event->summary;
101
102 // event properly registered
103 $added[] = $appData;
104 }
105 }
106 catch (Exception $e)
107 {
108 // track error
109 $this->setError($e);
110 }
111 }
112
113 return $added;
114 }
115
116 /**
117 * Imports the specified event.
118 *
119 * @param VAPIcalEvent $event The event to import.
120 *
121 * @return mixed The appointment data on success, false otherwise.
122 *
123 * @throws Exception
124 */
125 public function importEvent(VAPIcalEvent $event)
126 {
127 $data = [];
128
129 ///////////////////////////////////
130 // fetch event unique identifier //
131 ///////////////////////////////////
132
133 if (!$event->uid)
134 {
135 // missing event ID
136 throw new Exception('Event ID not specified', 400);
137 }
138
139 $data['icaluid'] = $event->uid;
140
141 if ($this->calendarHash)
142 {
143 // register within the details of the appointment the source
144 // calendar from which the events has been downloaded
145 $data['icalhash'] = $this->calendarHash;
146 }
147
148 // load reservation model
149 $model = JModelVAP::getInstance('reservation');
150
151 if (!$model)
152 {
153 // an error occurred, model not found...
154 throw new Exception('Missing reservation model', 500);
155 }
156
157 // check whether we already fetched the specified event
158 $item = $model->getItem([
159 'icaluid' => $event->uid,
160 ]);
161
162 if ($item)
163 {
164 // do reservation update
165 $data['id'] = $item->id;
166
167 // get last modify of the appointment
168 $dt = JFactory::getDate($item->modifiedon ? $item->modifiedon : $item->createdon)->toISO8601();
169
170 // update item only in case the event has a modify date greater than
171 // the one saved in the database
172 if ($dt >= $event->getLastModify())
173 {
174 // nothing has changed, avoid to process the event
175 return false;
176 }
177 }
178
179 //////////////////////////////
180 // fetch check-in date time //
181 //////////////////////////////
182
183 if (!$event->start)
184 {
185 // missing start date time
186 throw new Exception('Event start date time not specified', 400);
187 }
188
189 $data['checkin_ts'] = JFactory::getDate($event->start)->toSql();
190
191 ////////////////////////////////
192 // fetch appointment duration //
193 ////////////////////////////////
194
195 // extract duration from event (convert seconds in minutes)
196 $data['duration'] = $event->duration / 60;
197
198 if (!$data['duration'])
199 {
200 // missing duration
201 throw new Exception($event . ': event duration not specified', 400);
202 }
203
204 ///////////////////////
205 // fetch employee ID //
206 ///////////////////////
207
208 if ($this->employee)
209 {
210 // use the configured employee
211 $data['id_employee'] = $this->employee;
212 }
213 else
214 {
215 if (!$event->organizer)
216 {
217 // missing employee
218 throw new Exception($event . ': event organizer (employee) not specified', 400);
219 }
220
221 // get employee model
222 $employeeModel = JModelVAP::getInstance('employee');
223
224 if (!$employeeModel)
225 {
226 // an error occurred, model not found...
227 throw new Exception('Missing employee model', 500);
228 }
229
230 // check if we have an employee assigned to the specified e-mail
231 $employee = $employeeModel->getItem(array('email' => $event->organizer));
232
233 if (!$employee)
234 {
235 // missing employee
236 throw new Exception(sprintf($event . ': event organizer [%s] not found', $event->organizer), 404);
237 }
238
239 // register found employee
240 $data['id_employee'] = $employee->id;
241 }
242
243 /**
244 * If we have a closure, register the event as such.
245 *
246 * @since 1.7.7
247 */
248 if ($this->isClosure($event))
249 {
250 return $this->importClosure($data);
251 }
252
253 //////////////////////
254 // fetch service ID //
255 //////////////////////
256
257 if ($this->service)
258 {
259 // use the configured service
260 $data['id_service'] = $this->service;
261
262 // service already specified, assume the summary is the name/mail of the customer
263 if (strpos($event->summary, '@') !== false)
264 {
265 // e-mail address given
266 $data['purchaser_mail'] = trim($event->summary);
267 }
268 else
269 {
270 // fallback to customer nominative
271 $data['purchaser_nominative'] = trim($event->summary);
272 }
273 }
274 else
275 {
276 // find the service that fit at best the given event
277 $data['id_service'] = $this->findService($data, $event);
278
279 if (!$data['id_service'])
280 {
281 // no assigned services
282 throw new Exception(sprintf($event . ': event organizer [%s] does not support any services', $event->organizer), 500);
283 }
284 }
285
286 //////////////////////////////////
287 // fetch number of participants //
288 //////////////////////////////////
289
290 // fetch attendees list
291 $attendees = $event->getAttendeesList();
292
293 if (!$attendees)
294 {
295 // use default number of participants
296 $data['people'] = 1;
297 }
298 else
299 {
300 // number of participants equals to the number of attendees
301 $data['people'] = max(array(1, count($attendees)));
302
303 // use the first available e-mail
304 $data['purchaser_mail'] = array_shift($attendees);
305
306 // check whether we still have other attendees to register
307 if ($attendees)
308 {
309 $data['attendees'] = array();
310
311 foreach ($attendees as $email)
312 {
313 // register only the attendee e-mail
314 $data['attendees'][] = array('purchaser_mail' => $email);
315 }
316 }
317 }
318
319 /////////////////////////
320 // fetch customer name //
321 /////////////////////////
322
323 if (!empty($data['purchaser_mail']))
324 {
325 // get customer model
326 $customerModel = JModelVAP::getInstance('customer');
327
328 if (!$customerModel)
329 {
330 // an error occurred, model not found...
331 throw new Exception('Missing customer model', 500);
332 }
333
334 // check if we have a customer assigned to the specified e-mail
335 $customer = $customerModel->getItem(array('billing_mail' => $data['purchaser_mail']));
336
337 // does the customer exist?
338 if ($customer)
339 {
340 // yep, inject customer details
341 $data['purchaser_nominative'] = $customer->billing_name;
342 $data['purchaser_phone'] = $customer->billing_phone;
343 $data['purchaser_country'] = $customer->country_code;
344 $data['id_user'] = $customer->id;
345 }
346 }
347
348 /////////////////////////
349 // register user notes //
350 /////////////////////////
351
352 if ($event->description)
353 {
354 // register description contents within the reservation notes
355 $data['notes'] = nl2br($event->description);
356 }
357
358 ////////////////////////////
359 // create new appointment //
360 ////////////////////////////
361
362 // force a confirmed status for the event to import
363 $data['status'] = JHtml::fetch('vaphtml.status.confirmed', 'appointments', 'code');
364
365 // remind that the appointment has been imported from a remote iCal
366 $data['status_comment'] = 'VAP_STATUS_CHANGED_ON_ICAL_IMPORT';
367
368 if ($this->validateAvailability)
369 {
370 // validate the availability of the appointment to avoid conflicts
371 $data['validate_availability'] = 'admin';
372 }
373
374 if (empty($data['id']))
375 {
376 // recalculate the totals
377 $model->recalculateTotals($data);
378 }
379
380 // save appointment details
381 $id = $model->save($data);
382
383 if (!$id)
384 {
385 // an error occurred, retrieve error from model
386 $error = $model->getError();
387
388 if (!$error instanceof Exception)
389 {
390 $error = new Exception($event . ': ' . ($error ? $error : 'Error'), 500);
391 }
392
393 throw $error;
394 }
395
396 return $model->getData();
397 }
398
399 /**
400 * Checks whether the provided event should be registered as a closure.
401 *
402 * @param VAPIcalEvent $event The event to check.
403 *
404 * @return bool True if we have a closure, false otherwise.
405 */
406 protected function isClosure(VAPIcalEvent $event)
407 {
408 /**
409 * @todo extend list of keywords that should trigger the closure import
410 */
411
412 return in_array(
413 strtolower($event->summary),
414 [
415 'closure',
416 'day-off',
417 ]
418 );
419 }
420
421 /**
422 * Imports the specified closure.
423 *
424 * @param array $data The closure to import.
425 *
426 * @return mixed The closure data on success, false otherwise.
427 *
428 * @throws Exception
429 *
430 * @since 1.7.7
431 */
432 protected function importClosure(array $data)
433 {
434 // load closure model
435 $model = JModelVAP::getInstance('closure');
436
437 if (!$model)
438 {
439 // an error occurred, model not found...
440 throw new Exception('Missing closure model', 500);
441 }
442
443 // save closure details
444 $id = $model->save($data);
445
446 if (!$id)
447 {
448 // an error occurred, retrieve error from model
449 $error = $model->getError();
450
451 if (!$error instanceof Exception)
452 {
453 $error = new Exception($event . ': ' . ($error ? $error : 'Error'), 500);
454 }
455
456 throw $error;
457 }
458
459 return $model->getData();
460 }
461
462 /**
463 * Tries to extract the service from the given payload.
464 *
465 * @param array $data The appointment data to save.
466 * @param VAPIcalEvent $event The payload event.
467 *
468 * @return integer The service ID.
469 */
470 protected function findService($data, $event)
471 {
472 static $services = [];
473
474 if (!isset($services[$data['id_employee']]))
475 {
476 // load all services supported by this employee
477 $services[$data['id_employee']] = JModelVAP::getInstance('employee')->getServices($data['id_employee']);
478 }
479
480 // first of all check if we have a service with the name
481 // contained into the summary or in the description
482 foreach ($services[$data['id_employee']] as $service)
483 {
484 if (stripos((string) $event->summary, $service->name) !== false || stripos((string) $event->description, $service->name))
485 {
486 // name found, return service ID
487 return (int) $service->id;
488 }
489 }
490
491 // otherwise look for the first service with matching duration
492 foreach ($services[$data['id_employee']] as $service)
493 {
494 if ($service->duration == $data['duration'])
495 {
496 // matching duration, return service ID
497 return (int) $service->id;
498 }
499 }
500
501 // return first available service as fallback
502 return $services[$data['id_employee']] ? (int) $services[$data['id_employee']][0]->id : 0;
503 }
504
505 /**
506 * Detects the events that have been manually removed and internally takes action.
507 *
508 * @param VAPIcalEvent[] $events The available events.
509 * @param bool $delete Whether the appointments should be permanently
510 * deleted or whether the status should be updated.
511 *
512 * @return int[] A list of cancelled appointments.
513 *
514 * @since 1.7.5
515 */
516 public function cancel(array $events, bool $delete = false)
517 {
518 if (!$this->calendarHash)
519 {
520 // calendar hash not provided, do not go ahead
521 return [];
522 }
523
524 // load reservation model
525 $model = JModelVAP::getInstance('reservation');
526
527 if (!$model)
528 {
529 // an error occurred, model not found...
530 throw new Exception('Missing reservation model', 500);
531 }
532
533 // take only the ID of the imported events
534 $events = array_map(function($event) {
535 return $event->uid;
536 }, $events);
537
538 $deletedEvents = [];
539
540 // iterate the reservations one by one
541 foreach ($this->getCalendarAppointments() as $internalEvent)
542 {
543 if (in_array($internalEvent->icaluid, $events))
544 {
545 // the event is still available on the remote calendar
546 continue;
547 }
548
549 // the event is no longer available on the remote calendar...
550 if ($delete)
551 {
552 // permanently delete the appointment
553 $result = $model->delete($internalEvent->id);
554 }
555 else
556 {
557 $result = $model->save([
558 'id' => $internalEvent->id,
559 'icaluid' => '', // clear the reference to the remote ical ID
560 'status' => JHtml::fetch('vaphtml.status.cancelled', 'appointments', 'code'),
561 // register a note about the status change
562 'status_comment' => 'VAP_STATUS_CHANGED_ON_ICAL_DELETE',
563 ]);
564 }
565
566 if ($result)
567 {
568 $deletedEvents[] = $internalEvent->id;
569 }
570 else
571 {
572 // get last error
573 $error = $model->getError();
574
575 if (!$error instanceof Exception)
576 {
577 $error = new Exception($error);
578 }
579
580 $this->setError($error);
581 }
582 }
583
584 return $deletedEvents;
585 }
586
587 /**
588 * Returns all the appointments that have been imported from the
589 * same resource specified in the importer constructor.
590 *
591 * @return object[]
592 *
593 * @since 1.7.5
594 */
595 protected function getCalendarAppointments()
596 {
597 $db = JFactory::getDbo();
598
599 // take all the reservations imported from the specified source
600 $query = $db->getQuery(true)
601 ->select($db->qn(['id', 'icaluid']))
602 ->from($db->qn('#__vikappointments_reservation'))
603 ->where($db->qn('icalhash') . ' = ' . $db->q($this->calendarHash))
604 // the check-in must be in the future
605 ->where($db->qn('checkin_ts') . ' > ' . $db->q(JFactory::getDate()->toSql()));
606
607 // get any approved codes
608 $approved = JHtml::fetch('vaphtml.status.find', 'code', ['appointments' => 1, 'approved' => 1]);
609
610 if ($approved)
611 {
612 // the status must be approved
613 $query->where($db->qn('status') . ' IN (' . implode(',', array_map(array($db, 'q'), $approved)) . ')');
614 }
615
616 $db->setQuery($query);
617 return $db->loadObjectList();
618 }
619 }
620