Spaces:
No application file
No application file
namespace Mautic\FormBundle\Model; | |
use Doctrine\ORM\EntityManager; | |
use Doctrine\ORM\ORMException; | |
use Mautic\CampaignBundle\Entity\Campaign; | |
use Mautic\CampaignBundle\Membership\MembershipManager; | |
use Mautic\CampaignBundle\Model\CampaignModel; | |
use Mautic\CoreBundle\Exception\FileUploadException; | |
use Mautic\CoreBundle\Helper\Chart\ChartQuery; | |
use Mautic\CoreBundle\Helper\Chart\LineChart; | |
use Mautic\CoreBundle\Helper\CoreParametersHelper; | |
use Mautic\CoreBundle\Helper\DateTimeHelper; | |
use Mautic\CoreBundle\Helper\InputHelper; | |
use Mautic\CoreBundle\Helper\IpLookupHelper; | |
use Mautic\CoreBundle\Helper\UserHelper; | |
use Mautic\CoreBundle\Model\FormModel as CommonFormModel; | |
use Mautic\CoreBundle\Security\Permissions\CorePermissions; | |
use Mautic\CoreBundle\Translation\Translator; | |
use Mautic\CoreBundle\Twig\Helper\DateHelper; | |
use Mautic\FormBundle\Crate\UploadFileCrate; | |
use Mautic\FormBundle\Entity\Action; | |
use Mautic\FormBundle\Entity\Field; | |
use Mautic\FormBundle\Entity\Form; | |
use Mautic\FormBundle\Entity\Submission; | |
use Mautic\FormBundle\Entity\SubmissionRepository; | |
use Mautic\FormBundle\Event\Service\FieldValueTransformer; | |
use Mautic\FormBundle\Event\SubmissionEvent; | |
use Mautic\FormBundle\Event\ValidationEvent; | |
use Mautic\FormBundle\Exception\FileValidationException; | |
use Mautic\FormBundle\Exception\NoFileGivenException; | |
use Mautic\FormBundle\Exception\ValidationException; | |
use Mautic\FormBundle\FormEvents; | |
use Mautic\FormBundle\Helper\FormFieldHelper; | |
use Mautic\FormBundle\Helper\FormUploader; | |
use Mautic\FormBundle\ProgressiveProfiling\DisplayManager; | |
use Mautic\FormBundle\Validator\UploadFieldValidator; | |
use Mautic\LeadBundle\DataObject\LeadManipulator; | |
use Mautic\LeadBundle\Deduplicate\ContactMerger; | |
use Mautic\LeadBundle\Deduplicate\Exception\SameContactException; | |
use Mautic\LeadBundle\Entity\Company; | |
use Mautic\LeadBundle\Entity\Lead; | |
use Mautic\LeadBundle\Helper\CustomFieldValueHelper; | |
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper; | |
use Mautic\LeadBundle\Model\CompanyModel; | |
use Mautic\LeadBundle\Model\FieldModel as LeadFieldModel; | |
use Mautic\LeadBundle\Model\LeadModel; | |
use Mautic\LeadBundle\Tracker\ContactTracker; | |
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface; | |
use Mautic\PageBundle\Model\PageModel; | |
use PhpOffice\PhpSpreadsheet\IOFactory; | |
use PhpOffice\PhpSpreadsheet\Spreadsheet; | |
use Psr\Log\LoggerInterface; | |
use Symfony\Component\EventDispatcher\EventDispatcherInterface; | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\HttpFoundation\StreamedResponse; | |
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |
use Twig\Environment; | |
/** | |
* @extends CommonFormModel<Submission> | |
*/ | |
class SubmissionModel extends CommonFormModel | |
{ | |
public function __construct( | |
protected IpLookupHelper $ipLookupHelper, | |
protected Environment $twig, | |
protected FormModel $formModel, | |
protected PageModel $pageModel, | |
protected LeadModel $leadModel, | |
protected CampaignModel $campaignModel, | |
protected MembershipManager $membershipManager, | |
protected LeadFieldModel $leadFieldModel, | |
protected CompanyModel $companyModel, | |
protected FormFieldHelper $fieldHelper, | |
private UploadFieldValidator $uploadFieldValidator, | |
private FormUploader $formUploader, | |
private DeviceTrackingServiceInterface $deviceTrackingService, | |
private FieldValueTransformer $fieldValueTransformer, | |
private DateHelper $dateHelper, | |
private ContactTracker $contactTracker, | |
private ContactMerger $contactMerger, | |
EntityManager $em, | |
CorePermissions $security, | |
EventDispatcherInterface $dispatcher, | |
UrlGeneratorInterface $router, | |
Translator $translator, | |
UserHelper $userHelper, | |
LoggerInterface $mauticLogger, | |
CoreParametersHelper $coreParametersHelper | |
) { | |
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper); | |
} | |
public function getRepository(): SubmissionRepository | |
{ | |
return $this->em->getRepository(Submission::class); | |
} | |
/** | |
* @param bool $returnEvent | |
* | |
* @return bool|array | |
* | |
* @throws ORMException | |
*/ | |
public function saveSubmission($post, $server, Form $form, Request $request, $returnEvent = false) | |
{ | |
$leadFields = $this->leadFieldModel->getFieldListWithProperties(false); | |
// everything matches up so let's save the results | |
$submission = new Submission(); | |
$submission->setDateSubmitted(new \DateTime()); | |
$submission->setForm($form); | |
// set the landing page the form was submitted from if applicable | |
if (!empty($post['mauticpage'])) { | |
$page = $this->pageModel->getEntity((int) $post['mauticpage']); | |
if (null != $page) { | |
$submission->setPage($page); | |
} | |
} | |
$ipAddress = $this->ipLookupHelper->getIpAddress(); | |
$submission->setIpAddress($ipAddress); | |
if (!empty($post['return'])) { | |
$referer = $post['return']; | |
} elseif (!empty($server['HTTP_REFERER'])) { | |
$referer = $server['HTTP_REFERER']; | |
} else { | |
$referer = ''; | |
} | |
// clean the referer by removing mauticError and mauticMessage | |
$referer = InputHelper::url($referer, null, null, ['mauticError', 'mauticMessage']); | |
$submission->setReferer($referer); | |
// Create an event to be dispatched through the processes | |
$submissionEvent = new SubmissionEvent($submission, $post, $server, $request); | |
// Get a list of components to build custom fields from | |
$components = $this->formModel->getCustomComponents(); | |
$fields = $form->getFields(); | |
$fieldArray = []; | |
$results = []; | |
$tokens = []; | |
$leadFieldMatches = []; | |
$validationErrors = []; | |
$filesToUpload = new UploadFileCrate(); | |
/** @var Field $f */ | |
foreach ($fields as $f) { | |
$id = $f->getId(); | |
$type = $f->getType(); | |
$alias = $f->getAlias(); | |
$value = $post[$alias] ?? ''; | |
$fieldArray[$id] = [ | |
'id' => $id, | |
'type' => $type, | |
'alias' => $alias, | |
]; | |
if ($f->isCaptchaType()) { | |
$captcha = $this->fieldHelper->validateFieldValue($type, $value, $f); | |
if (!empty($captcha)) { | |
$props = $f->getProperties(); | |
// check for a custom message | |
$validationErrors[$alias] = (!empty($props['errorMessage'])) ? $props['errorMessage'] : implode('<br />', $captcha); | |
} | |
continue; | |
} elseif ($f->isFileType()) { | |
try { | |
$file = $this->uploadFieldValidator->processFileValidation($f, $request); | |
$value = $file->getClientOriginalName(); | |
$filesToUpload->addFile($file, $f); | |
} catch (NoFileGivenException) { // No error here, we just move to another validation, eg. if a field is required | |
} catch (FileValidationException $e) { | |
$validationErrors[$alias] = $e->getMessage(); | |
} | |
} | |
if (!$f->showForConditionalField($post)) { | |
continue; | |
} | |
if ('' === $value && $f->isRequired()) { | |
// field is required, but hidden from form because of 'ShowWhenValueExists' | |
if (false === $f->getShowWhenValueExists() && !isset($post[$alias])) { | |
continue; | |
} | |
// somehow the user got passed the JS validation | |
$msg = $f->getValidationMessage(); | |
if (empty($msg)) { | |
$msg = $this->translator->trans( | |
'mautic.form.field.generic.validationfailed', | |
[ | |
'%label%' => $f->getLabel(), | |
], | |
'validators' | |
); | |
} | |
$validationErrors[$alias] = $msg; | |
continue; | |
} | |
if (isset($components['viewOnlyFields']) && in_array($type, $components['viewOnlyFields'])) { | |
// don't save items that don't have a value associated with it | |
continue; | |
} | |
// clean and validate the input | |
if ($f->isCustom()) { | |
if (!isset($components['fields'][$f->getType()])) { | |
continue; | |
} | |
$params = $components['fields'][$f->getType()]; | |
if (!empty($value)) { | |
if (isset($params['valueFilter'])) { | |
if (is_string($params['valueFilter']) && is_callable([InputHelper::class, $params['valueFilter']])) { | |
$value = InputHelper::_($value, $params['valueFilter']); | |
} elseif (is_callable($params['valueFilter'])) { | |
$value = call_user_func_array($params['valueFilter'], [$f, $value]); | |
} else { | |
$value = InputHelper::_($value, 'clean'); | |
} | |
} else { | |
$value = InputHelper::_($value, 'clean'); | |
} | |
} | |
} elseif (!empty($value)) { | |
$filter = $this->fieldHelper->getFieldFilter($type); | |
$value = InputHelper::_($value, $filter); | |
$isValid = $this->validateFieldValue($f, $value); | |
if (true !== $isValid) { | |
$validationErrors[$alias] = is_array($isValid) ? implode('<br />', $isValid) : $isValid; | |
} | |
} | |
// Check for custom validators | |
$isValid = $this->validateFieldValue($f, $value); | |
if (true !== $isValid) { | |
$validationErrors[$alias] = $isValid; | |
} | |
$mappedField = $f->getMappedField(); | |
if (!empty($mappedField) && in_array($f->getMappedObject(), ['company', 'contact'])) { | |
$leadValue = $value; | |
$leadFieldMatches[$mappedField] = $leadValue; | |
} | |
$tokens["{formfield={$alias}}"] = $this->normalizeValue($value, $f); | |
// convert array from checkbox groups and multiple selects | |
if (is_array($value)) { | |
$value = implode(', ', $value); | |
} | |
// save the result | |
if (false !== $f->getSaveResult()) { | |
$results[$alias] = $value; | |
} | |
} | |
// Set the results | |
$submission->setResults($results); | |
// Update the event | |
$submissionEvent->setFields($fieldArray) | |
->setTokens($tokens) | |
->setResults($results) | |
->setContactFieldMatches($leadFieldMatches); | |
$lead = $this->contactTracker->getContact(); | |
// Remove validation errors if the field is not visible | |
if ($lead && $form->usesProgressiveProfiling()) { | |
$leadSubmissions = $this->formModel->getLeadSubmissions($form, $lead->getId()); | |
$displayManager = new DisplayManager($form, $this->formModel->getCustomComponents()['viewOnlyFields']); | |
foreach ($fields as $field) { | |
if ($field->showForContact($leadSubmissions, $lead, $form, $displayManager)) { | |
$displayManager->increaseDisplayedFields($field); | |
} elseif (isset($validationErrors[$field->getAlias()])) { | |
unset($validationErrors[$field->getAlias()]); | |
} | |
} | |
} | |
// return errors if there any | |
if (!empty($validationErrors)) { | |
return ['errors' => $validationErrors]; | |
} | |
// Create/update lead | |
if (!empty($leadFieldMatches)) { | |
$lead = $this->createLeadFromSubmit($form, $leadFieldMatches, $leadFields); | |
} | |
$trackedDevice = $this->deviceTrackingService->getTrackedDevice(); | |
$trackingId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId()); | |
// set tracking ID for stats purposes to determine unique hits | |
$submission->setTrackingId($trackingId) | |
->setLead($lead); | |
/* | |
* Process File upload and save the result to the entity | |
* Upload is here to minimize a need for deleting file if there is a validation error | |
* The action can still be invalidated below - deleteEntity takes care for File deletion | |
* | |
* @todo Refactor form validation to execute this code only if Submission is valid | |
*/ | |
try { | |
$this->formUploader->uploadFiles($filesToUpload, $submission); | |
} catch (FileUploadException $e) { | |
$msg = $this->translator->trans('mautic.form.submission.error.file.uploadFailed', [], 'validators'); | |
$validationErrors[$e->getMessage()] = $msg; | |
return ['errors' => $validationErrors]; | |
} | |
// set results after uploader what can change file name if file name exists | |
$submissionEvent->setResults($submission->getResults()); | |
// Save the submission | |
$this->saveEntity($submission); | |
$this->fieldValueTransformer->transformValuesAfterSubmit($submissionEvent); | |
// Now handle post submission actions | |
try { | |
$this->executeFormActions($submissionEvent); | |
} catch (ValidationException $exception) { | |
// The action invalidated the form for whatever reason | |
$this->deleteEntity($submission); | |
if ($validationErrors = $exception->getViolations()) { | |
return ['errors' => $validationErrors]; | |
} | |
return ['errors' => [$exception->getMessage()]]; | |
} | |
// update contact fields with transform values | |
if (!empty($this->fieldValueTransformer->getContactFieldsToUpdate())) { | |
$this->leadModel->setFieldValues($lead, $this->fieldValueTransformer->getContactFieldsToUpdate()); | |
$this->leadModel->saveEntity($lead, false); | |
} | |
if (!$form->isStandalone()) { | |
// Find and add the lead to the associated campaigns | |
$campaigns = $this->campaignModel->getCampaignsByForm($form); | |
/** @var Campaign $campaign */ | |
foreach ($campaigns as $campaign) { | |
if ($campaign->isPublished()) { | |
$this->membershipManager->addContact($lead, $campaign); | |
} | |
} | |
} | |
if ($this->dispatcher->hasListeners(FormEvents::FORM_ON_SUBMIT)) { | |
// Reset action config from executeFormActions() | |
$submissionEvent->setAction(null); | |
// Dispatch to on submit listeners | |
$this->dispatcher->dispatch($submissionEvent, FormEvents::FORM_ON_SUBMIT); | |
} | |
// get callback commands from the submit action | |
if ($submissionEvent->hasPostSubmitCallbacks()) { | |
return ['callback' => $submissionEvent]; | |
} | |
// made it to the end so return the submission event to give the calling method access to tokens, results, etc | |
// otherwise return false that no errors were encountered (to keep BC really) | |
return ($returnEvent) ? ['submission' => $submissionEvent] : false; | |
} | |
/** | |
* @param Submission $submission | |
*/ | |
public function deleteEntity($submission): void | |
{ | |
$this->formUploader->deleteUploadedFiles($submission); | |
parent::deleteEntity($submission); | |
} | |
public function getEntities(array $args = []) | |
{ | |
return $this->getRepository()->getEntities($args); | |
} | |
/** | |
* @param array<string, mixed> $args | |
* | |
* @return array<mixed> | |
*/ | |
public function getEntitiesByPage(array $args = []): array | |
{ | |
return $this->getRepository()->getEntitiesByPage($args); | |
} | |
/** | |
* @return StreamedResponse|Response | |
* | |
* @throws \Exception | |
*/ | |
public function exportResults($format, $form, $queryArgs) | |
{ | |
$viewOnlyFields = $this->formModel->getCustomComponents()['viewOnlyFields']; | |
$queryArgs['viewOnlyFields'] = $viewOnlyFields; | |
$queryArgs['simpleResults'] = true; | |
$results = $this->getEntities($queryArgs); | |
$date = (new DateTimeHelper())->toLocalString(); | |
$name = str_replace(' ', '_', $date).'_'.$form->getAlias(); | |
switch ($format) { | |
case 'csv': | |
$response = new StreamedResponse( | |
function () use ($results, $form, $viewOnlyFields): void { | |
$handle = fopen('php://output', 'r+'); | |
// build the header row | |
$header = $this->getExportHeader($form, $viewOnlyFields); | |
// write the row | |
$this->putCsvExportRow($handle, $header); | |
// build the data rows | |
foreach ($results as $k => $s) { | |
$row = $this->getExportRow($s, $viewOnlyFields); | |
$this->putCsvExportRow($handle, $row); | |
// free memory | |
unset($row, $results[$k]); | |
} | |
fclose($handle); | |
} | |
); | |
$this->setResponseHeaders($response, $name.'.csv', [ | |
'application/force-download', | |
'application/octet-stream', | |
]); | |
return $response; | |
case 'html': | |
$content = $this->twig->render( | |
'@MauticForm/Result/export.html.twig', | |
[ | |
'form' => $form, | |
'results' => $results, | |
'pageTitle' => $name, | |
'viewOnlyFields' => $viewOnlyFields, | |
] | |
); | |
return new Response($content); | |
case 'xlsx': | |
if (class_exists(Spreadsheet::class)) { | |
$response = new StreamedResponse( | |
function () use ($results, $form, $name, $viewOnlyFields): void { | |
$objPHPExcel = new Spreadsheet(); | |
$objPHPExcel->getProperties()->setTitle($name); | |
$objPHPExcel->createSheet(); | |
// build the header row | |
$header = $this->getExportHeader($form, $viewOnlyFields); | |
// write the row | |
$objPHPExcel->getActiveSheet()->fromArray($header, null, 'A1'); | |
// build the data rows | |
$count = 2; | |
foreach ($results as $k => $s) { | |
$row = $this->getExportRow($s, $viewOnlyFields); | |
$objPHPExcel->getActiveSheet()->fromArray($row, null, "A{$count}"); | |
// free memory | |
unset($row, $results[$k]); | |
// increment letter | |
++$count; | |
} | |
$objWriter = IOFactory::createWriter($objPHPExcel, 'Xlsx'); | |
$objWriter->setPreCalculateFormulas(false); | |
$objWriter->save('php://output'); | |
} | |
); | |
$this->setResponseHeaders($response, $name.'.xlsx', [ | |
'application/force-download', | |
'application/octet-stream', | |
]); | |
return $response; | |
} | |
throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets'); | |
default: | |
return new Response(); | |
} | |
} | |
/** | |
* @param string $format | |
* @param object $page | |
* @param array<string, mixed> $queryArgs | |
* | |
* @return StreamedResponse|Response | |
* | |
* @throws \Exception | |
*/ | |
public function exportResultsForPage($format, $page, $queryArgs) | |
{ | |
$results = $this->getEntitiesByPage($queryArgs); | |
$results = $results['results']; | |
$date = (new DateTimeHelper())->toLocalString(); | |
$name = str_replace(' ', '_', $date).'_'.$page->getAlias(); | |
switch ($format) { | |
case 'csv': | |
$response = new StreamedResponse( | |
function () use ($results): void { | |
$handle = fopen('php://output', 'r+'); | |
// build the header row | |
$header = $this->getExportHeaderForPage(); | |
$this->putCsvExportRow($handle, $header); | |
// build the data rows | |
foreach ($results as $k => $s) { | |
$row = $this->getExportRowForPage($s); | |
$this->putCsvExportRow($handle, $row); | |
// free memory | |
unset($row, $results[$k]); | |
} | |
fclose($handle); | |
} | |
); | |
$this->setResponseHeaders($response, $name.'.csv', [ | |
'text/csv; charset=UTF-8', | |
]); | |
return $response; | |
case 'html': | |
$content = $this->twig->render( | |
'@MauticPage/Result/export.html.twig', | |
[ | |
'page' => $page, | |
'results' => $results, | |
'pageTitle' => $name, | |
] | |
); | |
return new Response($content); | |
case 'xlsx': | |
if (!class_exists(Spreadsheet::class)) { | |
throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets'); | |
} | |
$response = new StreamedResponse( | |
function () use ($results, $name): void { | |
$objPHPExcel = new Spreadsheet(); | |
$objPHPExcel->getProperties()->setTitle($name); | |
$objPHPExcel->createSheet(); | |
$header = $this->getExportHeaderForPage('xlsx'); | |
// write the row | |
$objPHPExcel->getActiveSheet()->fromArray($header, null, 'A1'); | |
// build the data rows | |
$count = 2; | |
foreach ($results as $k => $s) { | |
$row = $this->getExportRowForPage($s, 'xlsx'); | |
$objPHPExcel->getActiveSheet()->fromArray($row, null, "A{$count}"); | |
// free memory | |
unset($row, $results[$k]); | |
// increment letter | |
++$count; | |
} | |
$objWriter = IOFactory::createWriter($objPHPExcel, 'Xlsx'); | |
$objWriter->setPreCalculateFormulas(false); | |
$objWriter->save('php://output'); | |
} | |
); | |
$this->setResponseHeaders($response, $name.'.xlsx', [ | |
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
]); | |
return $response; | |
default: | |
return new Response(); | |
} | |
} | |
/** | |
* @param array<string> $contentType | |
*/ | |
private function setResponseHeaders(StreamedResponse $response, string $filename, array $contentType): void | |
{ | |
foreach ($contentType as $ct) { | |
$response->headers->set('Content-Type', $ct); | |
} | |
$response->headers->set('Content-Disposition', 'attachment; filename="'.$filename.'"'); | |
$response->headers->set('Expires', '0'); | |
$response->headers->set('Cache-Control', 'must-revalidate'); | |
$response->headers->set('Pragma', 'public'); | |
} | |
/** | |
* @param resource $handle | |
* @param array<mixed> $row | |
*/ | |
private function putCsvExportRow($handle, array $row): bool|int | |
{ | |
return fputcsv($handle, $row); | |
} | |
/** | |
* @param array<mixed> $values | |
* | |
* @return array<mixed> | |
*/ | |
private function getExportRowForPage(array $values, string $format = 'csv'): array | |
{ | |
$row = [ | |
$values['id'], | |
$values['leadId'], | |
$this->dateHelper->toFull($values['dateSubmitted'], 'UTC'), | |
$values['ipAddress'], | |
$values['referer'], | |
]; | |
if ('csv' === $format) { | |
array_splice($row, 2, 0, $values['formId']); | |
} | |
return $row; | |
} | |
/** | |
* @param array<mixed> $values | |
* @param array<mixed> $viewOnlyFields | |
* | |
* @return array<mixed> | |
*/ | |
private function getExportRow(array $values, array $viewOnlyFields = []): array | |
{ | |
$row = [ | |
$values['id'], | |
$values['leadId'], | |
$this->dateHelper->toFull($values['dateSubmitted'], 'UTC'), | |
$values['ipAddress'], | |
$values['referer'], | |
]; | |
foreach ($values['results'] as $k2 => $r) { | |
if (in_array($r['type'], $viewOnlyFields)) { | |
continue; | |
} | |
$row[] = htmlspecialchars_decode($r['value'], ENT_QUOTES); | |
// free memory | |
unset($values['results'][$k2]); | |
} | |
return $row; | |
} | |
/** | |
* @return array<string> | |
*/ | |
private function getExportHeaderForPage(string $format = 'csv'): array | |
{ | |
$header = [ | |
$this->translator->trans('mautic.form.report.submission.id'), | |
$this->translator->trans('mautic.lead.report.contact_id'), | |
$this->translator->trans('mautic.form.result.thead.date'), | |
$this->translator->trans('mautic.core.ipaddress'), | |
$this->translator->trans('mautic.form.result.thead.referrer'), | |
]; | |
if ('csv' === $format) { | |
array_splice($header, 2, 0, $this->translator->trans('mautic.form.report.form_id')); | |
} | |
return $header; | |
} | |
/** | |
* @param array<mixed> $viewOnlyFields | |
* | |
* @return array<string> | |
*/ | |
private function getExportHeader(Form $form, $viewOnlyFields): array | |
{ | |
$fields = $form->getFields(); | |
$header = [ | |
$this->translator->trans('mautic.form.report.submission.id'), | |
$this->translator->trans('mautic.lead.report.contact_id'), | |
$this->translator->trans('mautic.form.result.thead.date'), | |
$this->translator->trans('mautic.core.ipaddress'), | |
$this->translator->trans('mautic.form.result.thead.referrer'), | |
]; | |
foreach ($fields as $f) { | |
if (in_array($f->getType(), $viewOnlyFields) || false === $f->getSaveResult()) { | |
continue; | |
} | |
$header[] = $f->getLabel(); | |
} | |
return $header; | |
} | |
/** | |
* Get line chart data of submissions. | |
* | |
* @param string|null $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters} | |
* @param string $dateFormat | |
* @param array $filter | |
* @param bool $canViewOthers | |
*/ | |
public function getSubmissionsLineChartData( | |
?string $unit, | |
\DateTime $dateFrom, | |
\DateTime $dateTo, | |
$dateFormat = null, | |
$filter = [], | |
$canViewOthers = true | |
): array { | |
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat); | |
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$q = $query->prepareTimeDataQuery('form_submissions', 'date_submitted', $filter); | |
if (!$canViewOthers) { | |
$q->join('t', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = t.form_id') | |
->andWhere('f.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
$data = $query->loadAndBuildTimeData($q); | |
$chart->setDataset($this->translator->trans('mautic.form.submission.count'), $data); | |
return $chart->render(); | |
} | |
/** | |
* Get a list of top submission referrers. | |
* | |
* @param int $limit | |
* @param string $dateFrom | |
* @param string $dateTo | |
* @param array $filters | |
* @param bool $canViewOthers | |
* | |
* @return array | |
*/ | |
public function getTopSubmissionReferrers($limit = 10, $dateFrom = null, $dateTo = null, $filters = [], $canViewOthers = true) | |
{ | |
$q = $this->em->getConnection()->createQueryBuilder(); | |
$q->select('COUNT(DISTINCT t.id) AS submissions, t.referer') | |
->from(MAUTIC_TABLE_PREFIX.'form_submissions', 't') | |
->orderBy('submissions', 'DESC') | |
->groupBy('t.referer') | |
->setMaxResults($limit); | |
if (!$canViewOthers) { | |
$q->join('t', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = t.form_id') | |
->andWhere('f.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$chartQuery->applyFilters($q, $filters); | |
$chartQuery->applyDateFilters($q, 'date_submitted'); | |
return $q->execute()->fetchAllAssociative(); | |
} | |
/** | |
* Get a list of the most submisions per lead. | |
* | |
* @param int $limit | |
* @param string $dateFrom | |
* @param string $dateTo | |
* @param array $filters | |
* @param bool $canViewOthers | |
* | |
* @return array | |
*/ | |
public function getTopSubmitters($limit = 10, $dateFrom = null, $dateTo = null, $filters = [], $canViewOthers = true) | |
{ | |
$q = $this->em->getConnection()->createQueryBuilder(); | |
$q->select('COUNT(DISTINCT t.id) AS submissions, t.lead_id, l.firstname, l.lastname, l.email') | |
->from(MAUTIC_TABLE_PREFIX.'form_submissions', 't') | |
->join('t', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = t.lead_id') | |
->orderBy('submissions', 'DESC') | |
->groupBy('t.lead_id, l.firstname, l.lastname, l.email') | |
->setMaxResults($limit); | |
if (!$canViewOthers) { | |
$q->join('t', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = t.form_id') | |
->andWhere('f.created_by = :userId') | |
->setParameter('userId', $this->userHelper->getUser()->getId()); | |
} | |
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo); | |
$chartQuery->applyFilters($q, $filters); | |
$chartQuery->applyDateFilters($q, 'date_submitted'); | |
return $q->execute()->fetchAllAssociative(); | |
} | |
/** | |
* Execute a form submit action. | |
* | |
* @throws ValidationException | |
*/ | |
protected function executeFormActions(SubmissionEvent $event): void | |
{ | |
$actions = $event->getSubmission()->getForm()->getActions(); | |
$customComponents = $this->formModel->getCustomComponents(); | |
$availableActions = $customComponents['actions'] ?? []; | |
$actions->filter(fn (Action $action): bool => array_key_exists($action->getType(), $availableActions))->map(function (Action $action) use ($event, $availableActions): void { | |
$event->setAction($action); | |
$this->dispatcher->dispatch($event, $availableActions[$action->getType()]['eventName']); | |
}); | |
} | |
/** | |
* Create/update lead from form submit. | |
* | |
* @throws ORMException | |
*/ | |
protected function createLeadFromSubmit(Form $form, array $leadFieldMatches, $leadFields): Lead | |
{ | |
// set the mapped data | |
$inKioskMode = $form->isInKioskMode(); | |
$leadId = null; | |
$lead = new Lead(); | |
$currentFields = $leadFieldMatches; | |
$companyFields = $this->leadFieldModel->getFieldListWithProperties('company'); | |
if (!$inKioskMode) { | |
// Default to currently tracked lead | |
if ($currentLead = $this->contactTracker->getContact()) { | |
$lead = $currentLead; | |
$leadId = $lead->getId(); | |
$currentFields = $lead->getProfileFields(); | |
} | |
$this->logger->debug('FORM: Not in kiosk mode so using current contact ID #'.$leadId); | |
} else { | |
// Default to a new lead in kiosk mode | |
$lead->setNewlyCreated(true); | |
$this->logger->debug('FORM: In kiosk mode so assuming a new contact'); | |
} | |
$uniqueLeadFields = $this->leadFieldModel->getUniqueIdentifierFields(); | |
// Closure to get data and unique fields | |
$getData = function ($currentFields, $uniqueOnly = false) use ($leadFields, $uniqueLeadFields): array { | |
$uniqueFieldsWithData = $data = []; | |
foreach ($leadFields as $alias => $properties) { | |
if (isset($currentFields[$alias])) { | |
$value = $currentFields[$alias]; | |
$data[$alias] = $value; | |
// make sure the value is actually there and the field is one of our uniques | |
if (!empty($value) && array_key_exists($alias, $uniqueLeadFields)) { | |
$uniqueFieldsWithData[$alias] = $value; | |
} | |
} | |
} | |
return ($uniqueOnly) ? $uniqueFieldsWithData : [$data, $uniqueFieldsWithData]; | |
}; | |
// Closure to get data and unique fields | |
$getCompanyData = function ($currentFields) use ($companyFields): array { | |
$companyData = []; | |
// force add company contact field to company fields check | |
$companyFields = array_merge($companyFields, ['company'=> 'company']); | |
foreach ($companyFields as $alias => $properties) { | |
if (isset($currentFields[$alias])) { | |
$value = $currentFields[$alias]; | |
$companyData[$alias] = $value; | |
} | |
} | |
return $companyData; | |
}; | |
// Closure to help search for a conflict | |
$checkForIdentifierConflict = function ($fieldSet1, $fieldSet2): array { | |
// Find fields in both sets | |
$potentialConflicts = array_keys( | |
array_intersect_key($fieldSet1, $fieldSet2) | |
); | |
$this->logger->debug( | |
'FORM: Potential conflicts '.implode(', ', array_keys($potentialConflicts)).' = '.implode(', ', $potentialConflicts) | |
); | |
$conflicts = []; | |
foreach ($potentialConflicts as $field) { | |
if (!empty($fieldSet1[$field]) && !empty($fieldSet2[$field])) { | |
if (strtolower($fieldSet1[$field]) !== strtolower($fieldSet2[$field])) { | |
$conflicts[] = $field; | |
} | |
} | |
} | |
return [count($conflicts), $conflicts]; | |
}; | |
// Get data for the form submission | |
[$data, $uniqueFieldsWithData] = $getData($leadFieldMatches); | |
$this->logger->debug('FORM: Unique fields submitted include '.implode(', ', $uniqueFieldsWithData)); | |
// Check for duplicate lead | |
/** @var \Mautic\LeadBundle\Entity\Lead[] $leads */ | |
$leads = (!empty($uniqueFieldsWithData)) ? $this->em->getRepository(Lead::class)->getLeadsByUniqueFields( | |
$uniqueFieldsWithData, | |
$leadId | |
) : []; | |
$uniqueFieldsCurrent = $getData($currentFields, true); | |
if (count($leads)) { | |
$this->logger->debug(count($leads).' found based on unique identifiers'); | |
/** @var Lead $foundLead */ | |
$foundLead = $leads[0]; | |
$this->logger->debug('FORM: Testing contact ID# '.$foundLead->getId().' for conflicts'); | |
// Check for a conflict with the currently tracked lead | |
$foundLeadFields = $foundLead->getProfileFields(); | |
// Get unique identifier fields for the found lead then compare with the lead currently tracked | |
$uniqueFieldsFound = $getData($foundLeadFields, true); | |
[$hasConflict, $conflicts] = $checkForIdentifierConflict($uniqueFieldsFound, $uniqueFieldsCurrent); | |
if ($inKioskMode || $hasConflict || !$lead->getId()) { | |
// Use the found lead without merging because there is some sort of conflict with unique identifiers or in kiosk mode and thus should not merge | |
$lead = $foundLead; | |
if ($hasConflict) { | |
$this->logger->debug('FORM: Conflicts found in '.implode(', ', $conflicts).' so not merging'); | |
} else { | |
$this->logger->debug('FORM: In kiosk mode so not merging'); | |
} | |
} else { | |
$this->logger->debug('FORM: Merging contacts '.$lead->getId().' and '.$foundLead->getId()); | |
// Merge the found lead with currently tracked lead | |
try { | |
$lead = $this->contactMerger->merge($lead, $foundLead); | |
} catch (SameContactException) { | |
} | |
} | |
// Update unique fields data for comparison with submitted data | |
$currentFields = $lead->getProfileFields(); | |
$uniqueFieldsCurrent = $getData($currentFields, true); | |
} | |
if (!$inKioskMode) { | |
// Check for conflicts with the submitted data and the currently tracked lead | |
[$hasConflict, $conflicts] = $checkForIdentifierConflict($uniqueFieldsWithData, $uniqueFieldsCurrent); | |
$this->logger->debug( | |
'FORM: Current unique contact fields '.implode(', ', array_keys($uniqueFieldsCurrent)).' = '.implode(', ', $uniqueFieldsCurrent) | |
); | |
$this->logger->debug( | |
'FORM: Submitted unique contact fields '.implode(', ', array_keys($uniqueFieldsWithData)).' = '.implode(', ', $uniqueFieldsWithData) | |
); | |
if ($hasConflict) { | |
// There's a conflict so create a new lead | |
$lead = new Lead(); | |
$lead->setNewlyCreated(true); | |
$this->logger->debug( | |
'FORM: Conflicts found in '.implode(', ', $conflicts) | |
.' between current tracked contact and submitted data so assuming a new contact' | |
); | |
} | |
} | |
// check for existing IP address | |
$ipAddress = $this->ipLookupHelper->getIpAddress(); | |
// no lead was found by a mapped email field so create a new one | |
if ($lead->isNewlyCreated()) { | |
if (!$inKioskMode) { | |
$lead->addIpAddress($ipAddress); | |
$this->logger->debug('FORM: Associating '.$ipAddress->getIpAddress().' to contact'); | |
} | |
} elseif (!$inKioskMode) { | |
$leadIpAddresses = $lead->getIpAddresses(); | |
if (!$leadIpAddresses->contains($ipAddress)) { | |
$lead->addIpAddress($ipAddress); | |
$this->logger->debug('FORM: Associating '.$ipAddress->getIpAddress().' to contact'); | |
} | |
} | |
// set the mapped fields | |
$this->leadModel->setFieldValues($lead, $data, false, true, true); | |
// last active time | |
$lead->setLastActive(new \DateTime()); | |
// create a new lead | |
$lead->setManipulator(new LeadManipulator( | |
'form', | |
'submission', | |
$form->getId(), | |
$form->getName() | |
)); | |
$this->leadModel->saveEntity($lead, false); | |
if (!$inKioskMode) { | |
// Set the current lead which will generate tracking cookies | |
$this->contactTracker->setTrackedContact($lead); | |
} else { | |
// Set system current lead which will still allow execution of events without generating tracking cookies | |
$this->contactTracker->setSystemContact($lead); | |
} | |
$companyFieldMatches = $getCompanyData($leadFieldMatches); | |
if (!empty($companyFieldMatches)) { | |
[$company, $leadAdded, $companyEntity] = IdentifyCompanyHelper::identifyLeadsCompany($companyFieldMatches, $lead, $this->companyModel); | |
$companyChangeLog = null; | |
if ($leadAdded) { | |
$companyChangeLog = $lead->addCompanyChangeLogEntry('form', 'Identify Company', 'Lead added to the company, '.$company['companyname'], $company['id']); | |
} elseif ($companyEntity instanceof Company) { | |
$this->companyModel->setFieldValues($companyEntity, $companyFieldMatches); | |
$this->companyModel->saveEntity($companyEntity); | |
} | |
if (!empty($company) and $companyEntity instanceof Company) { | |
// Save after the lead in for new leads created through the API and maybe other places | |
$this->companyModel->addLeadToCompany($companyEntity, $lead); | |
$this->leadModel->setPrimaryCompany($companyEntity->getId(), $lead->getId()); | |
} | |
if (null !== $companyChangeLog) { | |
$this->companyModel->getCompanyLeadRepository()->detachEntity($companyChangeLog); | |
} | |
} | |
return $lead; | |
} | |
/** | |
* Validates a field value. | |
* | |
* @return bool|string True if valid; otherwise string with invalid reason | |
*/ | |
protected function validateFieldValue(Field $field, $value) | |
{ | |
$standardValidation = $this->fieldHelper->validateFieldValue($field->getType(), $value, $field); | |
if (!empty($standardValidation)) { | |
return $standardValidation; | |
} | |
$components = $this->formModel->getCustomComponents(); | |
foreach ([$field->getType(), 'form'] as $type) { | |
if (isset($components['validators'][$type])) { | |
if (!is_array($components['validators'][$type])) { | |
$components['validators'][$type] = [$components['validators'][$type]]; | |
} | |
foreach ($components['validators'][$type] as $validator) { | |
if (!is_array($validator)) { | |
$validator = ['eventName' => $validator]; | |
} | |
$event = $this->dispatcher->dispatch(new ValidationEvent($field, $value), $validator['eventName']); | |
if (!$event->isValid()) { | |
return $event->getInvalidReason(); | |
} | |
} | |
} | |
} | |
return true; | |
} | |
private function normalizeValue($value, Field $f): string | |
{ | |
$value = !is_array($value) ? [$value] : $value; | |
// select and multiselect normalization | |
if ($properties = $f->getProperties()['list'] ?? null) { | |
foreach ($value as $key => $item) { | |
$value[$key] = CustomFieldValueHelper::setValueFromPropertiesList($properties, $item); | |
} | |
} | |
return implode(', ', $value); | |
} | |
} | |