*/ class LeadApiController extends CommonApiController { use CustomFieldsApiControllerTrait; use FrequencyRuleTrait; use LeadDetailsTrait; public const MODEL_ID = 'lead.lead'; /** * @var LeadModel|null */ protected $model; private DoNotContactModel $doNotContactModel; public function __construct( CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, DoNotContactModel $doNotContactModel, AppVersion $appVersion, private ContactMerger $contactMerger, private UserHelper $userHelper, private IpLookupHelper $ipLookupHelper, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper, MauticFactory $factory ) { $this->doNotContactModel = $doNotContactModel; $leadModel = $modelFactory->getModel(self::MODEL_ID); \assert($leadModel instanceof LeadModel); $this->model = $leadModel; $this->entityClass = Lead::class; $this->entityNameOne = 'contact'; $this->entityNameMulti = 'contacts'; $this->serializerGroups = ['leadDetails', 'frequencyRulesList', 'doNotContactList', 'userList', 'stageList', 'publishDetails', 'ipAddress', 'tagList', 'utmtagsList']; parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper, $factory); $this->setCleaningRules(); } /** * Obtains a list of users for lead owner edits. * * @return Response */ public function getOwnersAction(Request $request) { if (!$this->security->isGranted( ['lead:leads:create', 'lead:leads:editown', 'lead:leads:editother'], 'MATCH_ONE' ) ) { return $this->accessDenied(); } $filter = $request->query->get('filter', null); $limit = $request->query->get('limit', null); $start = $request->query->get('start', null); $users = $this->model->getLookupResults('user', $filter, $limit, $start); $view = $this->view($users, Response::HTTP_OK); $context = $view->getContext()->setGroups(['userList']); $view->setContext($context); return $this->handleView($view); } protected function getTotalCountTtl(): ?int { return $this->coreParametersHelper->get('contact_api_count_cache_ttl', 5); } /** * Obtains a list of custom fields. * * @return Response */ public function getFieldsAction() { if (!$this->security->isGranted(['lead:leads:editown', 'lead:leads:editother'], 'MATCH_ONE')) { return $this->accessDenied(); } $fields = $this->getModel('lead.field')->getEntities( [ 'filter' => [ 'force' => [ [ 'column' => 'f.isPublished', 'expr' => 'eq', 'value' => true, 'object' => 'lead', ], ], ], ] ); $view = $this->view($fields, Response::HTTP_OK); $context = $view->getContext()->setGroups(['leadFieldList']); $view->setContext($context); return $this->handleView($view); } /** * Obtains a list of notes on a specific lead. * * @return Response */ public function getNotesAction(Request $request, $id) { $entity = $this->model->getEntity($id); if (null === $entity) { return $this->notFound(); } if (!$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother', $entity->getPermissionUser())) { return $this->accessDenied(); } $results = $this->getModel('lead.note')->getEntities( [ 'start' => $request->query->get('start', 0), 'limit' => $request->query->get('limit', $this->coreParametersHelper->get('default_pagelimit')), 'filter' => [ 'string' => $request->query->get('search', ''), 'force' => [ [ 'column' => 'n.lead', 'expr' => 'eq', 'value' => $entity, ], ], ], 'orderBy' => $request->query->get('orderBy', 'n.dateAdded'), 'orderByDir' => $request->query->get('orderByDir', 'DESC'), ] ); [$notes, $count] = $this->prepareEntitiesForView($results); $view = $this->view( [ 'total' => $count, 'notes' => $notes, ], Response::HTTP_OK ); $context = $view->getContext()->setGroups(['leadNoteDetails']); $view->setContext($context); return $this->handleView($view); } /** * Obtains a list of devices on a specific lead. * * @return Response */ public function getDevicesAction(Request $request, $id) { $entity = $this->model->getEntity($id); if (null === $entity) { return $this->notFound(); } if (!$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother', $entity->getPermissionUser())) { return $this->accessDenied(); } $results = $this->getModel('lead.device')->getEntities( [ 'start' => $request->query->get('start', 0), 'limit' => $request->query->get('limit', $this->coreParametersHelper->get('default_pagelimit')), 'filter' => [ 'string' => $request->query->get('search', ''), 'force' => [ [ 'column' => 'd.lead', 'expr' => 'eq', 'value' => $entity, ], ], ], 'orderBy' => $request->query->get('orderBy', 'd.dateAdded'), 'orderByDir' => $request->query->get('orderByDir', 'DESC'), ] ); [$devices, $count] = $this->prepareEntitiesForView($results); $view = $this->view( [ 'total' => $count, 'devices' => $devices, ], Response::HTTP_OK ); $context = $view->getContext()->setGroups(['leadDeviceDetails']); $view->setContext($context); return $this->handleView($view); } /** * Obtains a list of contact segments the contact is in. * * @return Response */ public function getListsAction($id) { $entity = $this->model->getEntity($id); if (null !== $entity) { if (!$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother', $entity->getPermissionUser())) { return $this->accessDenied(); } $lists = $this->model->getLists($entity, true, true); foreach ($lists as &$l) { unset($l['leads'][0]['leadlist_id']); unset($l['leads'][0]['lead_id']); $l = array_merge($l, $l['leads'][0]); unset($l['leads']); } $view = $this->view( [ 'total' => count($lists), 'lists' => $lists, ], Response::HTTP_OK ); return $this->handleView($view); } return $this->notFound(); } /** * Obtains a list of contact companies the contact is in. * * @return Response */ public function getCompaniesAction($id) { $entity = $this->model->getEntity($id); if (null === $entity) { return $this->notFound(); } if (!$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother', $entity->getPermissionUser())) { return $this->accessDenied(); } $companies = $this->model->getCompanies($entity); $view = $this->view( [ 'total' => count($companies), 'companies' => $companies, ], Response::HTTP_OK ); return $this->handleView($view); } /** * Obtains a list of campaigns the lead is part of. * * @return Response */ public function getCampaignsAction($id) { $entity = $this->model->getEntity($id); if (null !== $entity) { if (!$this->security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother', $entity->getPermissionUser())) { return $this->accessDenied(); } /** @var \Mautic\CampaignBundle\Model\CampaignModel $campaignModel */ $campaignModel = $this->getModel('campaign'); $campaigns = $campaignModel->getLeadCampaigns($entity, true); foreach ($campaigns as &$c) { if (!empty($c['lists'])) { $c['listMembership'] = array_keys($c['lists']); unset($c['lists']); } unset($c['leads'][0]['campaign_id']); unset($c['leads'][0]['lead_id']); $c = array_merge($c, $c['leads'][0]); unset($c['leads']); } $view = $this->view( [ 'total' => count($campaigns), 'campaigns' => $campaigns, ], Response::HTTP_OK ); return $this->handleView($view); } return $this->notFound(); } /** * Obtains a list of contact events. * * @return Response */ public function getActivityAction(Request $request, $id) { $entity = $this->model->getEntity($id); if (null === $entity) { return $this->notFound(); } if (!$this->checkEntityAccess($entity)) { return $this->accessDenied(); } return $this->getAllActivityAction($request, $entity); } /** * Obtains a list of contact events. * * @return Response */ public function getAllActivityAction(Request $request, $lead = null) { $canViewOwn = $this->security->isGranted('lead:leads:viewown'); $canViewOthers = $this->security->isGranted('lead:leads:viewother'); if (!$canViewOthers && !$canViewOwn) { return $this->accessDenied(); } $filters = $this->sanitizeEventFilter(InputHelper::clean($request->get('filters', []))); $limit = (int) $request->get('limit', 25); $page = (int) $request->get('page', 1); $order = InputHelper::clean($request->get('order', ['timestamp', 'DESC'])); [$events, $serializerGroups] = $this->model->getEngagements($lead, $filters, $order, $page, $limit, false); $view = $this->view($events); $context = $view->getContext()->setGroups($serializerGroups); $view->setContext($context); return $this->handleView($view); } /** * Adds a DNC to the contact. * * @return Response */ public function addDncAction(Request $request, $id, $channel) { $entity = $this->model->getEntity((int) $id); if (null === $entity) { return $this->notFound(); } if (!$this->checkEntityAccess($entity, 'edit')) { return $this->accessDenied(); } $channelId = (int) $request->request->get('channelId'); if ($channelId) { $channel = [$channel => $channelId]; } // If no reason is set, default to 3 (manual) $reason = (int) $request->request->get('reason', DoNotContact::MANUAL); // If a reason is set, but it's empty or 0, show an error. if (0 === $reason) { return $this->returnError( 'Invalid reason code given', Response::HTTP_BAD_REQUEST, ['Reason code needs to be an integer and higher than 0.'] ); } $comments = InputHelper::clean($request->request->get('comments')); $doNotContact = $this->doNotContactModel; $doNotContact->addDncForContact($entity->getId(), $channel, $reason, $comments); $view = $this->view([$this->entityNameOne => $entity]); return $this->handleView($view); } /** * Removes a DNC from the contact. * * @return Response */ public function removeDncAction($id, $channel) { $doNotContact = $this->doNotContactModel; $entity = $this->model->getEntity((int) $id); if (null === $entity) { return $this->notFound(); } if (!$this->checkEntityAccess($entity, 'edit')) { return $this->accessDenied(); } $result = $doNotContact->removeDncForContact($entity->getId(), $channel); $view = $this->view( [ 'recordFound' => $result, $this->entityNameOne => $entity, ] ); return $this->handleView($view); } /** * Add/Remove a UTM Tagset to/from the contact. * * @param int $id * @param string $method * @param array|int $data * * @return Response */ protected function applyUtmTagsAction($id, $method, $data) { $entity = $this->model->getEntity((int) $id); if (null === $entity) { return $this->notFound(); } if (!$this->checkEntityAccess($entity, 'edit')) { return $this->accessDenied(); } // calls add/remove method as appropriate $result = $this->model->$method($entity, $data); if (false === $result) { return $this->badRequest(); } if ('removeUtmTags' == $method) { $view = $this->view( [ 'recordFound' => $result, $this->entityNameOne => $entity, ] ); } else { $view = $this->view([$this->entityNameOne => $entity]); } return $this->handleView($view); } /** * Adds a UTM Tagset to the contact. * * @param int $id * * @return Response */ public function addUtmTagsAction(Request $request, $id) { return $this->applyUtmTagsAction($id, 'addUTMTags', $request->request->all()); } /** * Remove a UTM Tagset for the contact. * * @param int $id * @param int $utmid * * @return Response */ public function removeUtmTagsAction($id, $utmid) { return $this->applyUtmTagsAction($id, 'removeUtmTags', (int) $utmid); } /** * Creates new entity from provided params. * * @return object */ public function getNewEntity(array $params) { return $this->model->checkForDuplicateContact($params); } protected function prepareParametersForBinding(Request $request, $parameters, $entity, $action) { // Unset the tags from params to avoid a validation error if (isset($parameters['tags'])) { unset($parameters['tags']); } // keep existing tags foreach ($entity->getTags() as $tag) { $parameters['tags'][] = $tag->getId(); } // keep existing owner if it is not set or should be reset to null if (!array_key_exists('owner', $parameters) && $entity->getOwner()) { $parameters['owner'] = $entity->getOwner()->getId(); } // keep existing stage if it is not set or should be reset to null if (!array_key_exists('stage', $parameters) && $entity->getStage()) { $parameters['stage'] = $entity->getStage()->getId(); } return $parameters; } /** * @param Lead $entity * @param array $parameters * @param string $action */ protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit') { if ('edit' === $action) { // Merge existing duplicate contact based on unique fields if exist // new endpoints will leverage getNewEntity in order to return the correct status codes $existingEntity = $this->model->checkForDuplicateContact($this->entityRequestParameters); $contactMerger = $this->contactMerger; if ($entity->getId() && $existingEntity->getId()) { try { $entity = $contactMerger->merge($entity, $existingEntity); } catch (SameContactException) { } } elseif ($existingEntity->getId()) { $entity = $existingEntity; } } $manipulatorObject = $this->inBatchMode ? 'api-batch' : 'api-single'; $entity->setManipulator(new LeadManipulator( 'lead', $manipulatorObject, null, $this->userHelper->getUser()->getName() )); if (isset($parameters['companies'])) { $this->model->modifyCompanies($entity, $parameters['companies']); unset($parameters['companies']); } if (isset($parameters['owner'])) { $owner = $this->getModel('user.user')->getEntity((int) $parameters['owner']); $entity->setOwner($owner); unset($parameters['owner']); } if (isset($parameters['stage'])) { $stage = $this->getModel('stage.stage')->getEntity((int) $parameters['stage']); $entity->setStage($stage); unset($parameters['stage']); } if (isset($this->entityRequestParameters['tags'])) { $this->model->modifyTags($entity, $this->entityRequestParameters['tags'], null, false); } // Since the request can be from 3rd party, check for an IP address if included if (isset($this->entityRequestParameters['ipAddress'])) { $ipAddress = $this->ipLookupHelper->getIpAddress($this->entityRequestParameters['ipAddress']); \assert($ipAddress instanceof IpAddress); if (!$entity->getIpAddresses()->contains($ipAddress)) { $entity->addIpAddress($ipAddress); } unset($this->entityRequestParameters['ipAddress']); } // Check for lastActive date if (isset($this->entityRequestParameters['lastActive'])) { $lastActive = new DateTimeHelper($this->entityRequestParameters['lastActive']); $entity->setLastActive($lastActive->getDateTime()); unset($this->entityRequestParameters['lastActive']); } // Batch DNC settings if (!empty($parameters['doNotContact']) && is_array($parameters['doNotContact'])) { foreach ($parameters['doNotContact'] as $dnc) { $channel = !empty($dnc['channel']) ? $dnc['channel'] : 'email'; $comments = !empty($dnc['comments']) ? $dnc['comments'] : ''; $reason = (int) ArrayHelper::getValue('reason', $dnc, DoNotContact::MANUAL); $doNotContact = $this->doNotContactModel; if (DoNotContact::IS_CONTACTABLE === $reason) { if (!empty($entity->getId())) { // Remove DNC record $doNotContact->removeDncForContact($entity->getId(), $channel, false); } } elseif (empty($entity->getId())) { // Contact doesn't exist yet. Directly create a DNC record on the entity. $doNotContact->createDncRecord($entity, $channel, $reason, $comments); } else { // Add DNC record to existing contact $doNotContact->addDncForContact($entity->getId(), $channel, $reason, $comments, false); } } unset($parameters['doNotContact']); } if (!empty($parameters['frequencyRules'])) { $viewParameters = []; $data = $this->getFrequencyRuleFormData($entity, null, null, false, $parameters['frequencyRules']); if (true !== $frequencyForm = $this->getFrequencyRuleForm($entity, $viewParameters, $data)) { $formErrors = $this->getFormErrorMessages($frequencyForm); $msg = $this->getFormErrorMessage($formErrors); if (!$msg) { $msg = $this->translator->trans('mautic.core.error.badrequest', [], 'flashes'); } return $this->returnError($msg, Response::HTTP_BAD_REQUEST, $formErrors); } unset($parameters['frequencyRules']); } $isPostOrPatch = 'POST' === $this->requestStack->getCurrentRequest()->getMethod() || 'PATCH' === $this->requestStack->getCurrentRequest()->getMethod(); $this->setCustomFieldValues($entity, $form, $parameters, $isPostOrPatch); } /** * Helper method to be used in FrequencyRuleTrait. * * @param Form $form */ protected function isFormCancelled($form = null): bool { return false; } /** * Helper method to be used in FrequencyRuleTrait. */ protected function isFormValid(Form $form, array $data = null): bool { $form->submit($data, 'PATCH' !== $this->requestStack->getCurrentRequest()->getMethod()); return $form->isSubmitted() && $form->isValid(); } }