<?php
namespace App\Controller;
use App\Entity\Campaign;
use App\Entity\CampaignContact;
use App\Entity\CampaignLink;
use App\Entity\CampaignLinkHit;
use App\Entity\CustomFields;
use App\Entity\RecurringCampaign;
use App\Entity\SendEvent;
use App\Entity\ThreadSafe\ThreadSafeEntityManager;
use App\Entity\User;
use App\Event\CampaignContactUpdateEvent;
use App\Form\CampaignType;
use App\Service\Campaign\CampaignManager;
use App\Service\DataTables\SSP;
use App\Service\Stats;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Markup;
class CampaignsController extends UserPageController {
const PAGE_TITLE = 'page.title.campaigns';
protected $page;
protected $eventDispatcher;
/** @var User */
protected $user;
protected $em;
protected $stats;
protected $router;
protected $logger;
public function __construct(Security $security, TranslatorInterface $translator, EntityManagerInterface $entityManager, Stats $stats, RouterInterface $router, EventDispatcherInterface $eventDispatcher, LoggerInterface $logger)
{
$this->em = $entityManager;
$this->eventDispatcher = $eventDispatcher;
$this->stats = $stats;
$this->user = $security->getUser();
$this->router = $router;
$this->logger = $logger;
$this->addBreadcrumb('Dashboard', $this->router->generate('utmailer_dashboard'));
$this->addBreadcrumb($translator->trans('entity.campaign', ['num' => 2]), $this->router->generate('utmailer_campaigns'));
}
public function index() : Response
{
// Only access if user profile setup is complete
if(!$this->isGranted('setup-complete', $this->user) || !$this->isGranted('domain-auth-complete', $this->user)){
$this->addFlash('warning', $this->translator->trans('user.complete_profile_setup', ['setup_url' => $this->generateUrl('utmailer_user_setup_1')]));
return $this->redirectToRoute('utmailer_user_setup_1', [], 302);
}
$cRepo = $this->getDoctrine()->getRepository(Campaign::class);
$campaigns = $cRepo->findBy(array(
'user' => $this->user
), [ 'publish_at' => 'DESC' ]);
$campaignsList = array();
foreach ($campaigns as $campaign) {
$campaignsList[] = [
'campaign' => $campaign,
'stats' => $this->stats->getCampaignsStatsFromTotalCounts($this->user, $campaign, [
'contacts',
SendEvent::TYPE_DELIVERED,
SendEvent::TYPE_BOUNCED
])
];
}
$params = array(
'page' => $this->page,
'campaigns' => $campaignsList
);
return $this->render('campaigns-list.html.twig', $params);
}
public function view($id, Request $request) : Response
{
$cRepo = $this->getDoctrine()->getRepository(Campaign::class);
$campaign = $cRepo->find($id);
$this->addBreadcrumb($campaign->getName(), $this->router->generate('utmailer_campaign_view', ['id' => $id]));
// Is campaign not owned by user?
if(!$this->user->getCampaigns()->contains($campaign)){
return $this->redirect($this->generateUrl('utmailer_campaigns', array()), 301);
}
$campaignStats = $this->stats->getCampaignsStatsFromTotalCounts($this->user, $campaign, [
'contacts',
SendEvent::TYPE_DELIVERED,
SendEvent::TYPE_OPENED,
SendEvent::TYPE_CLICKED,
SendEvent::TYPE_BOUNCED
]);
$campaignTimeline = $this->stats->getCampaignTimeline($campaign, 15);
$labels = array();
$delivered = array();
$opened = array();
$bounced = array();
foreach ($campaignTimeline as $date => $stats){
$labels[] = $date;
$delivered[] = $stats['delivered'];
$opened[] = $stats['opened'];
$clicked[] = $stats['clicked'];
$bounced[] = $stats['bounced'];
}
$campaignChart = [
'labels' => $labels,
'datasets' => [
[
'label' => 'Delivered',
'data' => $delivered,
'style' => 'info'
],
[
'label' => 'Opened',
'data' => $opened,
'style' => 'primary'
],
[
'label' => 'Clicked',
'data' => $clicked,
'style' => 'success'
],
[
'label' => 'Bounced',
'data' => $bounced,
'style' => 'warning'
]
]
];
$cLinksRepo = $this->em->getRepository(CampaignLink::class);
$cLinks = $cLinksRepo->createQueryBuilder('l')
->addSelect('count(h.id) AS HIDDEN hitsNum')
->leftJoin('l.hits', 'h')
->where('l.campaign = :campaign')
->setParameter('campaign', $campaign)
->orderBy('hitsNum', 'DESC')
->groupBy('l.id')
->getQuery()
->getResult();
$links = array();
$cLinkHitsRepo = $this->em->getRepository(CampaignLinkHit::class);
foreach ($cLinks as $link){
$total = $cLinkHitsRepo->countTotalForLink($link);
$unique = $cLinkHitsRepo->countUniqueForLink($link);
$links[] = [
'link' => $link,
'hits' => $total,
'uniqueHits' => $unique
];
}
return $this->render('campaigns-view.html.twig', [
'page' => $this->page,
'campaign' => $campaign,
'links' => $links,
'campaign_stats' => $campaignStats,
'campaign_chart' => $campaignChart
]);
}
public function create(bool $recurring, Request $request, ValidatorInterface $validator, TranslatorInterface $translator, CampaignManager $cm) : Response
{
$this->addBreadcrumb($translator->trans('entity.actions.create', ['entity' => $translator->trans('entity.campaign', ['num'=>1])]), $this->router->generate('utmailer_campaign_create'));
// Only access if user profile setup is complete
if(!$this->isGranted('setup-complete', $this->user) || !$this->isGranted('domain-auth-complete', $this->user)){
$this->addFlash('warning', $this->translator->trans('user.complete_profile_setup', ['setup_url' => $this->generateUrl('utmailer_user_setup_1')]));
return $this->redirectToRoute('utmailer_user_setup_1', [], 302);
}
$form = $this->createForm(CampaignType::class, null, [
'recurring' => $recurring,
]);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
/** @var Campaign $campaign */
$campaign = $form->getData();
$campaign->setUser($this->user);
$errors = $validator->validate($campaign);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->addError(new FormError($error->getMessage()));
}
} else {
// If send button clicked, send now!
if(isset($request->request->get('campaign')['send']) && $this->user->getStatus() !== User::STATUS_SUSPENDED){
$campaign->setSent(true);
$campaign->setActive(true);
$campaign->setPublishAt(new \DateTime());
$task = $cm->makeSendTask($campaign);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($campaign);
$entityManager->flush();
$flash = new Markup($this->renderView('flashes/campaign-task-created.html.twig'), 'UTF-8');
$this->addFlash('success', $flash);
return new RedirectResponse($this->generateUrl('utmailer_campaign_view', ['id' => $campaign->getId()]), Response::HTTP_MOVED_PERMANENTLY);
} else {
if ($this->user->getStatus() === User::STATUS_SUSPENDED){
$campaign->setActive(false);
}
$campaign->setSent(false);
$entityManager = $this->getDoctrine()->getManager();
if ($recurring) {
$repeat = $form['repeat']->getData();
$contactGroups = [];
foreach ($form['contactGroups']->getData() as $cg) {
$contactGroups[] = $cg->getId();
};
$contactFilters = json_decode($form['contactFilters']->getData());
$rules = [
'contact_groups' => $contactGroups,
'contact_filters' => $contactFilters,
'repeat' => $repeat,
];
$recurringCampaign = new RecurringCampaign();
$recurringCampaign->setName($form['name']->getData());
$recurringCampaign->setStatus(($form['active']->getData()) ? 'active' : 'inactive');
$recurringCampaign->setRules($rules);
$recurringCampaign->addCampaign($campaign);
$entityManager->persist($recurringCampaign);
}
$entityManager->persist($campaign);
$entityManager->flush();
$this->addFlash('success', $translator->trans('campaign.created'));
return new RedirectResponse($this->generateUrl('utmailer_campaign_edit', ['id' => $campaign->getId()]));
}
}
}
$customFields = array();
if (!$user_custom_fields = $this->user->getCustomFields()) {
$user_custom_fields = new CustomFields();
$this->user->setCustomFields($user_custom_fields);
$this->em->persist($this->user);
$this->em->flush();
}
foreach ($user_custom_fields->getCustomFields() as $customFieldNum => $customField) {
if ($customField) {
$customFields[] = [$customFieldNum => $customField];
}
}
if ($recurring) {
return $this->render('recurring-campaigns-create.html.twig', [
'page' => $this->page,
'campaign_form' => $form->createView(),
'customFields' => $customFields
]);
}
return $this->render('campaigns-create.html.twig', [
'page' => $this->page,
'campaign_form' => $form->createView(),
'customFields' => $customFields
]);
}
public function edit($id, bool $recurring = false, Request $request, ValidatorInterface $validator, TranslatorInterface $translator, CampaignManager $cm) : Response
{
$cRepo = $this->getDoctrine()->getRepository(Campaign::class);
$campaign = $cRepo->find($id);
// Campaigns already sent cannot be modified
if($campaign->isSent()){
$this->addFlash('warning', $translator->trans('flashes.cannot_modify_sent_campaign'));
return new RedirectResponse($this->generateUrl('utmailer_campaign_view', ['id' => $campaign->getId()]), Response::HTTP_FOUND);
}
$this->addBreadcrumb($campaign->getName(), $this->router->generate('utmailer_campaign_edit', ['id' => $id]));
// Is campaign not owned by user?
if(!$this->user->getCampaigns()->contains($campaign)){
return $this->redirect($this->generateUrl('utmailer_campaigns', array()), 301);
}
$form = $this->createForm(CampaignType::class, $campaign, array('user' => $this->user));
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
$campaign = $form->getData();
$campaign->setUser($this->user);
$errors = $validator->validate($campaign);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->addError(new FormError($error->getMessage()));
}
} else {
// If send button clicked, send now!
if(isset($request->request->get('campaign')['send']) && $this->user->getStatus() !== User::STATUS_SUSPENDED){
$campaign->setSent(true);
$campaign->setActive(true);
$campaign->setPublishAt(new \DateTime());
$task = $cm->makeSendTask($campaign);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($campaign);
$entityManager->flush();
$flash = new Markup($this->renderView('flashes/campaign-task-created.html.twig'), 'UTF-8');
$this->addFlash('success', $flash);
return new RedirectResponse($this->generateUrl('utmailer_campaign_view', ['id' => $campaign->getId()]), Response::HTTP_MOVED_PERMANENTLY);
} else {
if ($this->user->getStatus() === User::STATUS_SUSPENDED){
$campaign->setActive(false);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($campaign);
$entityManager->flush();
$this->addFlash('success', $translator->trans('campaign.saved'));
return new RedirectResponse($this->generateUrl('utmailer_campaign_edit', ['id' => $campaign->getId()]));
}
}
}
$customFields = array();
if (!$user_custom_fields = $this->user->getCustomFields()) {
$user_custom_fields = new CustomFields();
$this->user->setCustomFields($user_custom_fields);
$this->em->persist($this->user);
$this->em->flush();
}
foreach ($user_custom_fields->getCustomFields() as $customFieldNum => $customField) {
if ($customField) {
$customFields[] = [$customFieldNum => $customField];
}
}
return $this->render('campaigns-edit.html.twig', [
'page' => $this->page,
'campaign_form' => $form->createView(),
'campaign' => $campaign,
'customFields' => $customFields,
]);
}
public function delete(Request $request, int $id, TranslatorInterface $translator) : Response
{
$cRepo = $this->em->getRepository(Campaign::class);
// Check campaign is owned by user
if($campaign = $cRepo->findOneBy(['user' => $this->user, 'id' => $id])){
$this->em->remove($campaign);
$this->em->flush();
}
$referer = $request->headers->get('referer');
if ($referer == NULL) {
$referer = $this->generateUrl('utmailer_campaigns');
}
$this->addFlash('success', $translator->trans('Campaign') . ' ' . $translator->trans('deleted', ['gender' => 'female', 'num' => '1']));
return new RedirectResponse($referer);
}
public function getBody($id) : Response
{
$cRepo = $this->getDoctrine()->getRepository(Email::class);
$email = $cRepo->find($id);
return new Response($email->getBody(), 200, ['Content-Type' => 'application/json']);
}
public function campaignLink($token, ThreadSafeEntityManager $em) : Response
{
$tok = explode('.', $token);
// check token parts
if (count($tok) !== 3) {
// not valid token
throw $this->createNotFoundException('Resource not found');
}
// check link id segment
if (!isset($tok[0]) || !filter_var($tok[0], FILTER_VALIDATE_INT)) {
// not valid id
throw $this->createNotFoundException('Resource not found');
}
// check campaign contact id segment
if (!isset($tok[1]) || !filter_var($tok[1], FILTER_VALIDATE_INT)) {
// not valid id
throw $this->createNotFoundException('Resource not found');
}
$linkId = $tok[0];
$campaignContactId = $tok[1];
$sRepo = $this->em->getRepository(SendEvent::class);
if(($link = $this->em->getRepository(CampaignLink::class)->find($linkId)) && ($campaignContact = $this->em->getRepository(CampaignContact::class)->find($campaignContactId))){
if ($tok[2] !== hash_hmac('sha256', $link->getUrl().$campaignContact->getId(), $this->getParameter('app.secret'))) {
// failed verification
throw $this->createNotFoundException('Resource not found');
} else {
// Record link hit
$linkHit = new CampaignLinkHit();
$linkHit->setCampaignContact($campaignContact);
$linkHit->setCampaignLink($link);
$linkHit->setDateCreated(new \DateTime());
$this->em->persist($linkHit);
$this->em->flush();
// Save Clicked sendEvent only if not already exists
if(!$sRepo->findOneBy(['campaign_contact' => $campaignContact->getId(), 'event' => 'clicked'])){
// Record clicked SendEvent, campaign contact status will be updated by event listener
$sendEvent = new SendEvent();
$sendEvent->setCampaignContact($campaignContact);
$sendEvent->setCreatedAt(new \DateTime());
$sendEvent->setEvent('clicked');
$this->em->persist($sendEvent);
$this->em->flush();
// Update campaign contact and campaign totals with thread safety
$callback = function() use ($em, $campaignContactId) {
// Get repository inside callable to make sure EntityManager is valid
$ccRepo = $em->getRepository(CampaignContact::class);
// Fetch account with FOR UPDATE write lock
$campaignContact = $ccRepo->find(
$campaignContactId,
LockMode::PESSIMISTIC_WRITE
);
if(in_array($campaignContact->getStatus(), [
CampaignContact::STATUS_PENDING,
CampaignContact::STATUS_PROCESSED,
CampaignContact::STATUS_DEFERRED,
CampaignContact::STATUS_DELIVERED,
CampaignContact::STATUS_OPENED
])){
$campaignContact->setStatus(CampaignContact::STATUS_CLICKED);
$campaign = $em->getRepository(Campaign::class)->find($campaignContact->getCampaign()->getId(), LockMode::PESSIMISTIC_WRITE);
$campaign->addDailyCount(SendEvent::TYPE_CLICKED);
}
return true;
};
$em->transactional($callback);
}
return new RedirectResponse($link->getUrl(), 301);
}
} else {
throw $this->createNotFoundException('Resource not found');
}
}
public function unsubscribe(Request $request, $token) : Response
{
$tok = explode('.', $token);
// check token parts
if (count($tok) !== 2) {
// not valid token
throw $this->createNotFoundException('Resource not found');
}
// check id segment
if (!isset($tok[0]) || !filter_var($tok[0], FILTER_VALIDATE_INT)) {
// not valid id
throw $this->createNotFoundException('Resource not found');
}
$campaignContactId = $tok[0];
if(!$campaignContact = $this->em->getRepository(CampaignContact::class)->find($campaignContactId)) {
throw $this->createNotFoundException('Resource not found');
}
$user = $campaignContact->getCampaign()->getUser();
$unsubForm = $this->createFormBuilder()->add('confirm', SubmitType::class, [
'label' => 'Confirm'
]);
if ($tok[1] !== hash_hmac('sha256', $campaignContact->getContact()->getId(), $this->getParameter('app.secret'))) {
// failed verification
throw $this->createNotFoundException('Resource not found');
}
$unsubForm = $this->createFormBuilder()->add('confirm', SubmitType::class, [
'label' => 'Confirm'
])->getForm();
$unsubForm->handleRequest($request);
if($unsubForm->isSubmitted() && $unsubForm->isValid()){
$sendEvent = new SendEvent();
$sendEvent->setCampaignContact($campaignContact);
$sendEvent->setCreatedAt(new \DateTime('@'.time()));
$sendEvent->setEvent('unsubscribed');
$event = new CampaignContactUpdateEvent($sendEvent);
$this->eventDispatcher->dispatch($event, $event::NAME);
$sendEventResult = $event->getSendEvent();
$this->em->persist($sendEventResult);
$this->em->flush();
$this->addFlash('success', 'You unsubscribed successfully');
return $this->render('unsubscribe-success.html.twig', [
'user' => $user,
'page' => $this->page,
]);
}
return $this->render('unsubscribe.html.twig', [
'page' => $this->page,
'user' => $user,
'unsubscribe_form' => $unsubForm->createView()
]);
}
public function getContactsJson(Request $request, SSP $ssp, int $id) : JsonResponse
{
$output = $this->getOutput($request, $ssp, [
['campaign', 'eq', $id]
//['status', 'neq', 'deleted']
]);
return new JsonResponse($output);
}
private function getOutput(Request $request, SSP $ssp, array $fixed_filters = []) : array
{
$columns = array();
foreach ($request->query->get('columns') as $dtColumn){
if($dtColumn['data']=='' || is_numeric($dtColumn['data'])){
$column = [
'dt' => null,
'db' => null,
'searchable' => false
];
} else {
$column = [
'dt' => $dtColumn['data'],
'db' => $dtColumn['name'],
'searchable' => ($dtColumn['searchable'] === 'true') ? true : false,
'search' => $dtColumn['search']
];
if($dtColumn['name'] == 'createdAt'){
$column['formatter'] = function ($d, $row){
return $d->format('d/m/Y');
};
}
if($dtColumn['name'] == 'send_events'){
$column['formatter'] = function ($d, $row){
$sendEvents = array();
foreach (json_decode($d) as $eventId){
$sendEvent = $this->em->find(SendEvent::class, $eventId);
$sendEvents[] = [
'id' => $sendEvent->getId(),
'event' => $sendEvent->getEvent(),
'created_at' => $sendEvent->getCreatedAt()->format('d/m/Y H:i'),
'message' => $sendEvent->getMessage()
];
}
usort($sendEvents, function ($a, $b){
$aDate = \DateTime::createFromFormat('d/m/Y H:i', $a['created_at']);
$bDate = \DateTime::createFromFormat('d/m/Y H:i', $b['created_at']);
return ($aDate > $bDate) ? -1 : 1;
});
return $sendEvents;
};
}
}
$columns[] = $column;
}
$orders = [
//['updated_at', 'desc']
];
return $ssp->utsimple($this->getUser(), $request->query, $columns, CampaignContact::class, $orders, $fixed_filters);
}
public function webView(Request $request, string $token) : void
{
try {
$campaignContact = $this->parseCampaignContactToken($token);
} catch (\Exception $e) {
throw $this->createNotFoundException('Resource not found');
}
$campaign = $campaignContact->getCampaign();
$campaignData = $campaign->getCampaignData();
$substitutions = [];
$contact = $campaignContact->getContact();
// unsubscribe_url merge tag, defaults to # if not a campaign email (e.g. sending tests)
$unsubUrl = '#';
// Set recipient specific substitutions
if(strlen($contact->getFirstName()) != 0){
$substitutions['{{first_name}}'] = $contact->getFirstName();
$substitutions['{{contact_first_name}}'] = $contact->getFirstName();
}
if(strlen($contact->getLastName()) != 0){
$substitutions['{{last_name}}'] = $contact->getLastName();
$substitutions['{{contact_last_name}}'] = $contact->getLastName();
}
if(strlen($contact->getCompanyName()) != 0){
$substitutions['{{contact_company_name}}'] = $contact->getCompanyName();
}
$substitutions['{{company_name}}'] = $campaign->getUser()->getCompany();
$substitutions['{{unsubscribe_url}}'] = $unsubUrl;
// unsubscribe_url merge tag for campaign contact
$unsubToken = $campaignContact->getId().'.'.hash_hmac('sha256', $contact->getId(), $this->getParameter('app.secret'));
$unsubUrl = $this->router->generate('utmailer_unsubscribe', ['token' => $unsubToken], UrlGeneratorInterface::ABSOLUTE_URL);
$substitutions['{{unsubscribe_url}}'] = $unsubUrl;
// Add links substitutions
foreach ($campaign->getCampaignLinksAll() as $link){
if ($link->getUrl()){
$linkToken = $link->getId() . '.' . $campaignContact->getId() . '.'.hash_hmac('sha256', $link->getUrl().$campaignContact->getId(), $this->getParameter('app.secret'));
$linkUrl = $this->router->generate('utmailer_campaign_link', ['token' => $linkToken], UrlGeneratorInterface::ABSOLUTE_URL);
} else {
$linkUrl = '';
}
$substitutions['{{EMAIL_LINK_'.$link->getLinkId().'}}'] = $linkUrl;
}
echo str_replace(array_keys($substitutions), array_values($substitutions), $campaignData['html']);
exit;
}
private function parseCampaignContactToken(string $token) : CampaignContact
{
$tok = explode('.', $token);
// check token parts
if (count($tok) !== 2) {
// not valid token
throw $this->createNotFoundException('Resource not found');
}
// check id segment
if (!isset($tok[0]) || !filter_var($tok[0], FILTER_VALIDATE_INT)) {
// not valid id
throw $this->createNotFoundException('Resource not found');
}
$campaignContactId = $tok[0];
/** @var CampaignContact $campaignContact */
if(!$campaignContact = $this->em->getRepository(CampaignContact::class)->find($campaignContactId)) {
throw $this->createNotFoundException('Resource not found');
}
if ($tok[1] !== hash_hmac('sha256', $campaignContact->getContact()->getId(), $this->getParameter('app.secret'))) {
// failed verification
throw $this->createNotFoundException('Resource not found');
}
return $campaignContact;
}
}