1 | <?php |
||
2 | |||
3 | /* |
||
4 | * @copyright 2016 Mautic Contributors. All rights reserved |
||
5 | * @author Mautic |
||
6 | * |
||
7 | * @link http://mautic.org |
||
8 | * |
||
9 | * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html |
||
10 | */ |
||
11 | |||
12 | namespace Mautic\SmsBundle\Model; |
||
13 | |||
14 | use Doctrine\DBAL\Query\QueryBuilder; |
||
15 | use Mautic\ChannelBundle\Entity\MessageQueue; |
||
16 | use Mautic\ChannelBundle\Model\MessageQueueModel; |
||
17 | use Mautic\CoreBundle\Event\TokenReplacementEvent; |
||
18 | use Mautic\CoreBundle\Helper\CacheStorageHelper; |
||
19 | use Mautic\CoreBundle\Helper\Chart\ChartQuery; |
||
20 | use Mautic\CoreBundle\Helper\Chart\LineChart; |
||
21 | use Mautic\CoreBundle\Model\AjaxLookupModelInterface; |
||
22 | use Mautic\CoreBundle\Model\FormModel; |
||
23 | use Mautic\LeadBundle\Entity\DoNotContactRepository; |
||
24 | use Mautic\LeadBundle\Entity\Lead; |
||
25 | use Mautic\LeadBundle\Model\LeadModel; |
||
26 | use Mautic\PageBundle\Model\TrackableModel; |
||
27 | use Mautic\SmsBundle\Entity\Sms; |
||
28 | use Mautic\SmsBundle\Entity\Stat; |
||
29 | use Mautic\SmsBundle\Event\SmsEvent; |
||
30 | use Mautic\SmsBundle\Event\SmsSendEvent; |
||
31 | use Mautic\SmsBundle\Form\Type\SmsType; |
||
32 | use Mautic\SmsBundle\Sms\TransportChain; |
||
33 | use Mautic\SmsBundle\SmsEvents; |
||
34 | use Symfony\Component\EventDispatcher\Event; |
||
35 | use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; |
||
36 | |||
37 | class SmsModel extends FormModel implements AjaxLookupModelInterface |
||
38 | { |
||
39 | /** |
||
40 | * @var TrackableModel |
||
41 | */ |
||
42 | protected $pageTrackableModel; |
||
43 | |||
44 | /** |
||
45 | * @var LeadModel |
||
46 | */ |
||
47 | protected $leadModel; |
||
48 | |||
49 | /** |
||
50 | * @var MessageQueueModel |
||
51 | */ |
||
52 | protected $messageQueueModel; |
||
53 | |||
54 | /** |
||
55 | * @var TransportChain |
||
56 | */ |
||
57 | protected $transport; |
||
58 | |||
59 | /** |
||
60 | * @var CacheStorageHelper |
||
61 | */ |
||
62 | private $cacheStorageHelper; |
||
63 | |||
64 | public function __construct(TrackableModel $pageTrackableModel, LeadModel $leadModel, MessageQueueModel $messageQueueModel, TransportChain $transport, CacheStorageHelper $cacheStorageHelper) |
||
65 | { |
||
66 | $this->pageTrackableModel = $pageTrackableModel; |
||
67 | $this->leadModel = $leadModel; |
||
68 | $this->messageQueueModel = $messageQueueModel; |
||
69 | $this->transport = $transport; |
||
70 | $this->cacheStorageHelper = $cacheStorageHelper; |
||
71 | } |
||
72 | |||
73 | /** |
||
74 | * {@inheritdoc} |
||
75 | * |
||
76 | * @return \Mautic\SmsBundle\Entity\SmsRepository |
||
77 | */ |
||
78 | public function getRepository() |
||
79 | { |
||
80 | return $this->em->getRepository('MauticSmsBundle:Sms'); |
||
81 | } |
||
82 | |||
83 | /** |
||
84 | * @return \Mautic\SmsBundle\Entity\StatRepository |
||
85 | */ |
||
86 | public function getStatRepository() |
||
87 | { |
||
88 | return $this->em->getRepository('MauticSmsBundle:Stat'); |
||
89 | } |
||
90 | |||
91 | /** |
||
92 | * {@inheritdoc} |
||
93 | */ |
||
94 | public function getPermissionBase() |
||
95 | { |
||
96 | return 'sms:smses'; |
||
97 | } |
||
98 | |||
99 | /** |
||
100 | * Save an array of entities. |
||
101 | * |
||
102 | * @param $entities |
||
103 | * @param $unlock |
||
104 | * |
||
105 | * @return array |
||
106 | */ |
||
107 | public function saveEntities($entities, $unlock = true) |
||
108 | { |
||
109 | //iterate over the results so the events are dispatched on each delete |
||
110 | $batchSize = 20; |
||
111 | $i = 0; |
||
112 | foreach ($entities as $entity) { |
||
113 | $isNew = ($entity->getId()) ? false : true; |
||
114 | |||
115 | //set some defaults |
||
116 | $this->setTimestamps($entity, $isNew, $unlock); |
||
117 | |||
118 | if ($dispatchEvent = $entity instanceof Sms) { |
||
119 | $event = $this->dispatchEvent('pre_save', $entity, $isNew); |
||
120 | } |
||
121 | |||
122 | $this->getRepository()->saveEntity($entity, false); |
||
123 | |||
124 | if ($dispatchEvent) { |
||
125 | $this->dispatchEvent('post_save', $entity, $isNew, $event); |
||
126 | } |
||
127 | |||
128 | if (0 === ++$i % $batchSize) { |
||
129 | $this->em->flush(); |
||
130 | } |
||
131 | } |
||
132 | $this->em->flush(); |
||
133 | } |
||
134 | |||
135 | /** |
||
136 | * {@inheritdoc} |
||
137 | * |
||
138 | * @param $entity |
||
139 | * @param $formFactory |
||
140 | * @param null $action |
||
141 | * @param array $options |
||
142 | * |
||
143 | * @return mixed |
||
144 | * |
||
145 | * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException |
||
146 | * @throws MethodNotAllowedHttpException |
||
147 | */ |
||
148 | public function createForm($entity, $formFactory, $action = null, $options = []) |
||
149 | { |
||
150 | if (!$entity instanceof Sms) { |
||
151 | throw new MethodNotAllowedHttpException(['Sms']); |
||
152 | } |
||
153 | if (!empty($action)) { |
||
154 | $options['action'] = $action; |
||
155 | } |
||
156 | |||
157 | return $formFactory->create(SmsType::class, $entity, $options); |
||
158 | } |
||
159 | |||
160 | /** |
||
161 | * Get a specific entity or generate a new one if id is empty. |
||
162 | * |
||
163 | * @param $id |
||
164 | * |
||
165 | * @return Sms|null |
||
166 | */ |
||
167 | public function getEntity($id = null) |
||
168 | { |
||
169 | if (null === $id) { |
||
170 | $entity = new Sms(); |
||
171 | } else { |
||
172 | $entity = parent::getEntity($id); |
||
173 | } |
||
174 | |||
175 | return $entity; |
||
176 | } |
||
177 | |||
178 | /** |
||
179 | * Return a list of entities. |
||
180 | * |
||
181 | * @param array $args [start, limit, filter, orderBy, orderByDir] |
||
182 | * |
||
183 | * @return \Doctrine\ORM\Tools\Pagination\Paginator|array |
||
184 | */ |
||
185 | public function getEntities(array $args = []) |
||
186 | { |
||
187 | $entities = parent::getEntities($args); |
||
188 | |||
189 | foreach ($entities as $entity) { |
||
190 | $pending = $this->cacheStorageHelper->get(sprintf('%s|%s|%s', 'sms', $entity->getId(), 'pending')); |
||
191 | |||
192 | if (false !== $pending) { |
||
193 | $entity->setPendingCount($pending); |
||
194 | } |
||
195 | } |
||
196 | |||
197 | return $entities; |
||
198 | } |
||
199 | |||
200 | /** |
||
201 | * @param $sendTo |
||
202 | * @param array $options |
||
203 | * |
||
204 | * @return array |
||
205 | */ |
||
206 | public function sendSms(Sms $sms, $sendTo, $options = []) |
||
207 | { |
||
208 | $channel = (isset($options['channel'])) ? $options['channel'] : null; |
||
209 | $listId = (isset($options['listId'])) ? $options['listId'] : null; |
||
210 | |||
211 | if ($sendTo instanceof Lead) { |
||
212 | $sendTo = [$sendTo]; |
||
213 | } elseif (!is_array($sendTo)) { |
||
214 | $sendTo = [$sendTo]; |
||
215 | } |
||
216 | |||
217 | $sentCount = 0; |
||
218 | $failedCount = 0; |
||
219 | $results = []; |
||
220 | $contacts = []; |
||
221 | $fetchContacts = []; |
||
222 | foreach ($sendTo as $lead) { |
||
223 | if (!$lead instanceof Lead) { |
||
224 | $fetchContacts[] = $lead; |
||
225 | } else { |
||
226 | $contacts[$lead->getId()] = $lead; |
||
227 | } |
||
228 | } |
||
229 | |||
230 | if ($fetchContacts) { |
||
231 | $foundContacts = $this->leadModel->getEntities( |
||
232 | [ |
||
233 | 'ids' => $fetchContacts, |
||
234 | ] |
||
235 | ); |
||
236 | |||
237 | foreach ($foundContacts as $contact) { |
||
238 | $contacts[$contact->getId()] = $contact; |
||
239 | } |
||
240 | } |
||
241 | $contactIds = array_keys($contacts); |
||
242 | |||
243 | /** @var DoNotContactRepository $dncRepo */ |
||
244 | $dncRepo = $this->em->getRepository('MauticLeadBundle:DoNotContact'); |
||
245 | $dnc = $dncRepo->getChannelList('sms', $contactIds); |
||
246 | |||
247 | if (!empty($dnc)) { |
||
248 | foreach ($dnc as $removeMeId => $removeMeReason) { |
||
249 | $results[$removeMeId] = [ |
||
250 | 'sent' => false, |
||
251 | 'status' => 'mautic.sms.campaign.failed.not_contactable', |
||
252 | ]; |
||
253 | |||
254 | unset($contacts[$removeMeId], $contactIds[$removeMeId]); |
||
255 | } |
||
256 | } |
||
257 | |||
258 | if (!empty($contacts)) { |
||
259 | $messageQueue = (isset($options['resend_message_queue'])) ? $options['resend_message_queue'] : null; |
||
260 | $campaignEventId = (is_array($channel) && 'campaign.event' === $channel[0] && !empty($channel[1])) ? $channel[1] : null; |
||
261 | |||
262 | $queued = $this->messageQueueModel->processFrequencyRules( |
||
263 | $contacts, |
||
264 | 'sms', |
||
265 | $sms->getId(), |
||
266 | $campaignEventId, |
||
267 | 3, |
||
268 | MessageQueue::PRIORITY_NORMAL, |
||
269 | $messageQueue, |
||
270 | 'sms_message_stats' |
||
271 | ); |
||
272 | |||
273 | if ($queued) { |
||
274 | foreach ($queued as $queue) { |
||
275 | $results[$queue] = [ |
||
276 | 'sent' => false, |
||
277 | 'status' => 'mautic.sms.timeline.status.scheduled', |
||
278 | ]; |
||
279 | |||
280 | unset($contacts[$queue]); |
||
281 | } |
||
282 | } |
||
283 | |||
284 | $stats = []; |
||
285 | // @todo we should allow batch sending based on transport, MessageBird does support 20 SMS at once |
||
286 | // the transport chain is already prepared for it |
||
287 | if (count($contacts)) { |
||
288 | /** @var Lead $lead */ |
||
289 | foreach ($contacts as $lead) { |
||
290 | $leadId = $lead->getId(); |
||
291 | $stat = $this->createStatEntry($sms, $lead, $channel, false, $listId); |
||
292 | |||
293 | $leadPhoneNumber = $lead->getLeadPhoneNumber(); |
||
294 | |||
295 | if (empty($leadPhoneNumber)) { |
||
296 | $results[$leadId] = [ |
||
297 | 'sent' => false, |
||
298 | 'status' => 'mautic.sms.campaign.failed.missing_number', |
||
299 | ]; |
||
300 | |||
301 | continue; |
||
302 | } |
||
303 | |||
304 | $smsEvent = new SmsSendEvent($sms->getMessage(), $lead); |
||
305 | $smsEvent->setSmsId($sms->getId()); |
||
306 | $this->dispatcher->dispatch(SmsEvents::SMS_ON_SEND, $smsEvent); |
||
307 | |||
308 | $tokenEvent = $this->dispatcher->dispatch( |
||
309 | SmsEvents::TOKEN_REPLACEMENT, |
||
310 | new TokenReplacementEvent( |
||
311 | $smsEvent->getContent(), |
||
312 | $lead, |
||
313 | [ |
||
314 | 'channel' => [ |
||
315 | 'sms', // Keep BC pre 2.14.1 |
||
316 | $sms->getId(), // Keep BC pre 2.14.1 |
||
317 | 'sms' => $sms->getId(), |
||
318 | ], |
||
319 | 'stat' => $stat->getTrackingHash(), |
||
320 | ] |
||
321 | ) |
||
322 | ); |
||
323 | |||
324 | $sendResult = [ |
||
325 | 'sent' => false, |
||
326 | 'type' => 'mautic.sms.sms', |
||
327 | 'status' => 'mautic.sms.timeline.status.delivered', |
||
328 | 'id' => $sms->getId(), |
||
329 | 'name' => $sms->getName(), |
||
330 | 'content' => $tokenEvent->getContent(), |
||
331 | ]; |
||
332 | |||
333 | $metadata = $this->transport->sendSms($lead, $tokenEvent->getContent(), $stat); |
||
334 | if (true !== $metadata) { |
||
335 | $sendResult['status'] = $metadata; |
||
336 | $stat->setIsFailed(true); |
||
337 | if (is_string($metadata)) { |
||
338 | $stat->addDetail('failed', $metadata); |
||
339 | } |
||
340 | ++$failedCount; |
||
341 | } else { |
||
342 | $sendResult['sent'] = true; |
||
343 | ++$sentCount; |
||
344 | } |
||
345 | |||
346 | $stats[] = $stat; |
||
347 | unset($stat); |
||
348 | $results[$leadId] = $sendResult; |
||
349 | |||
350 | unset($smsEvent, $tokenEvent, $sendResult, $metadata); |
||
351 | } |
||
352 | } |
||
353 | } |
||
354 | |||
355 | if ($sentCount || $failedCount) { |
||
356 | $this->getRepository()->upCount($sms->getId(), 'sent', $sentCount); |
||
357 | $this->getStatRepository()->saveEntities($stats); |
||
358 | |||
359 | foreach ($stats as $stat) { |
||
360 | if (!$stat->isFailed()) { |
||
361 | $results[$stat->getLead()->getId()]['statId'] = $stat->getId(); |
||
362 | } |
||
363 | } |
||
364 | |||
365 | $this->em->clear(Stat::class); |
||
366 | } |
||
367 | |||
368 | return $results; |
||
369 | } |
||
370 | |||
371 | /** |
||
372 | * @param null $source |
||
373 | * @param bool $persist |
||
374 | * @param null $listId |
||
375 | * |
||
376 | * @return Stat |
||
377 | * |
||
378 | * @throws \Exception |
||
379 | */ |
||
380 | public function createStatEntry(Sms $sms, Lead $lead, $source = null, $persist = true, $listId = null) |
||
381 | { |
||
382 | $stat = new Stat(); |
||
383 | $stat->setDateSent(new \DateTime()); |
||
384 | $stat->setLead($lead); |
||
385 | $stat->setSms($sms); |
||
386 | if (null !== $listId) { |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
387 | $stat->setList($this->leadModel->getLeadListRepository()->getEntity($listId)); |
||
388 | } |
||
389 | if (is_array($source)) { |
||
390 | $stat->setSourceId($source[1]); |
||
391 | $source = $source[0]; |
||
392 | } |
||
393 | $stat->setSource($source); |
||
394 | $stat->setTrackingHash(str_replace('.', '', uniqid('', true))); |
||
395 | |||
396 | if ($persist) { |
||
397 | $this->getStatRepository()->saveEntity($stat); |
||
398 | } |
||
399 | |||
400 | return $stat; |
||
401 | } |
||
402 | |||
403 | /** |
||
404 | * {@inheritdoc} |
||
405 | * |
||
406 | * @param $action |
||
407 | * @param $event |
||
408 | * @param $entity |
||
409 | * @param $isNew |
||
410 | * |
||
411 | * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException |
||
412 | */ |
||
413 | protected function dispatchEvent($action, &$entity, $isNew = false, Event $event = null) |
||
414 | { |
||
415 | if (!$entity instanceof Sms) { |
||
416 | throw new MethodNotAllowedHttpException(['Sms']); |
||
417 | } |
||
418 | |||
419 | switch ($action) { |
||
420 | case 'pre_save': |
||
421 | $name = SmsEvents::SMS_PRE_SAVE; |
||
422 | break; |
||
423 | case 'post_save': |
||
424 | $name = SmsEvents::SMS_POST_SAVE; |
||
425 | break; |
||
426 | case 'pre_delete': |
||
427 | $name = SmsEvents::SMS_PRE_DELETE; |
||
428 | break; |
||
429 | case 'post_delete': |
||
430 | $name = SmsEvents::SMS_POST_DELETE; |
||
431 | break; |
||
432 | default: |
||
433 | return; |
||
434 | } |
||
435 | |||
436 | if ($this->dispatcher->hasListeners($name)) { |
||
437 | if (empty($event)) { |
||
438 | $event = new SmsEvent($entity, $isNew); |
||
439 | $event->setEntityManager($this->em); |
||
440 | } |
||
441 | |||
442 | $this->dispatcher->dispatch($name, $event); |
||
443 | |||
444 | return $event; |
||
445 | } else { |
||
446 | return; |
||
447 | } |
||
448 | } |
||
449 | |||
450 | /** |
||
451 | * Joins the page table and limits created_by to currently logged in user. |
||
452 | */ |
||
453 | public function limitQueryToCreator(QueryBuilder &$q) |
||
454 | { |
||
455 | $q->join('t', MAUTIC_TABLE_PREFIX.'sms_messages', 's', 's.id = t.sms_id') |
||
456 | ->andWhere('s.created_by = :userId') |
||
457 | ->setParameter('userId', $this->userHelper->getUser()->getId()); |
||
458 | } |
||
459 | |||
460 | /** |
||
461 | * Get line chart data of hits. |
||
462 | * |
||
463 | * @param char $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters} |
||
464 | * @param string $dateFormat |
||
465 | * @param array $filter |
||
466 | * @param bool $canViewOthers |
||
467 | * |
||
468 | * @return array |
||
469 | */ |
||
470 | public function getHitsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true) |
||
471 | { |
||
472 | $flag = null; |
||
473 | |||
474 | if (isset($filter['flag'])) { |
||
475 | $flag = $filter['flag']; |
||
476 | unset($filter['flag']); |
||
477 | } |
||
478 | |||
479 | $chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat); |
||
480 | $query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); |
||
481 | |||
482 | if (!$flag || 'total_and_unique' === $flag) { |
||
483 | $filter['is_failed'] = 0; |
||
484 | $q = $query->prepareTimeDataQuery('sms_message_stats', 'date_sent', $filter); |
||
485 | |||
486 | if (!$canViewOthers) { |
||
487 | $this->limitQueryToCreator($q); |
||
488 | } |
||
489 | |||
490 | $data = $query->loadAndBuildTimeData($q); |
||
491 | $chart->setDataset($this->translator->trans('mautic.sms.show.total.sent'), $data); |
||
492 | } |
||
493 | |||
494 | if (!$flag || 'failed' === $flag) { |
||
495 | $filter['is_failed'] = 1; |
||
496 | $q = $query->prepareTimeDataQuery('sms_message_stats', 'date_sent', $filter); |
||
497 | if (!$canViewOthers) { |
||
498 | $this->limitQueryToCreator($q); |
||
499 | } |
||
500 | |||
501 | $data = $query->loadAndBuildTimeData($q); |
||
502 | $chart->setDataset($this->translator->trans('mautic.sms.show.failed'), $data); |
||
503 | } |
||
504 | |||
505 | return $chart->render(); |
||
506 | } |
||
507 | |||
508 | /** |
||
509 | * @param $idHash |
||
510 | * |
||
511 | * @return Stat |
||
512 | */ |
||
513 | public function getSmsStatus($idHash) |
||
514 | { |
||
515 | return $this->getStatRepository()->getSmsStatus($idHash); |
||
516 | } |
||
517 | |||
518 | /** |
||
519 | * Search for an sms stat by sms and lead IDs. |
||
520 | * |
||
521 | * @param $smsId |
||
522 | * @param $leadId |
||
523 | * |
||
524 | * @return array |
||
525 | */ |
||
526 | public function getSmsStatByLeadId($smsId, $leadId) |
||
527 | { |
||
528 | return $this->getStatRepository()->findBy( |
||
529 | [ |
||
530 | 'sms' => (int) $smsId, |
||
531 | 'lead' => (int) $leadId, |
||
532 | ], |
||
533 | ['dateSent' => 'DESC'] |
||
534 | ); |
||
535 | } |
||
536 | |||
537 | /** |
||
538 | * Get an array of tracked links. |
||
539 | * |
||
540 | * @param $smsId |
||
541 | * |
||
542 | * @return array |
||
543 | */ |
||
544 | public function getSmsClickStats($smsId) |
||
545 | { |
||
546 | return $this->pageTrackableModel->getTrackableList('sms', $smsId); |
||
547 | } |
||
548 | |||
549 | /** |
||
550 | * @param $type |
||
551 | * @param string $filter |
||
552 | * @param int $limit |
||
553 | * @param int $start |
||
554 | * @param array $options |
||
555 | * |
||
556 | * @return array |
||
557 | */ |
||
558 | public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, $options = []) |
||
559 | { |
||
560 | $results = []; |
||
561 | switch ($type) { |
||
562 | case 'sms': |
||
563 | case SmsType::class: |
||
564 | $entities = $this->getRepository()->getSmsList( |
||
565 | $filter, |
||
566 | $limit, |
||
567 | $start, |
||
568 | $this->security->isGranted($this->getPermissionBase().':viewother'), |
||
569 | isset($options['sms_type']) ? $options['sms_type'] : null |
||
570 | ); |
||
571 | |||
572 | foreach ($entities as $entity) { |
||
573 | $results[$entity['language']][$entity['id']] = $entity['name']; |
||
574 | } |
||
575 | |||
576 | //sort by language |
||
577 | ksort($results); |
||
578 | |||
579 | break; |
||
580 | } |
||
581 | |||
582 | return $results; |
||
583 | } |
||
584 | } |
||
585 |