listingHandler = $listingHandler; $this->entityManager = $entityManager; $this->statisticsClicksRepository = $statisticsClicksRepository; $this->newslettersRepository = $newslettersRepository; $this->newsletterLinkRepository = $newsletterLinkRepository; } /** * @return Listing\ListingDefinition */ private function parseData($data): Listing\ListingDefinition { // check if sort order was specified or default to "desc" $data['sort_order'] = ($data['sort_order'] ?? null) === 'asc' ? 'asc' : 'desc'; // sanitize sort by $sortableColumns = ['email', 'status', 'created_at']; $sortBy = (!empty($data['sort_by']) && in_array($data['sort_by'], $sortableColumns, true)) ? $data['sort_by'] : ''; if (empty($sortBy)) { $sortBy = 'created_at'; } $data['sort_by'] = $sortBy; if (!empty($data['filter']['link'])) { $data['group'] = self::STATUS_CLICKED; } return $this->listingHandler->getListingDefinition($data); } /** * @param array{sort_order?: string, sort_by?: string, params?: array, group?: string, filter?: array{link: int|null}} $data * * @return array{ * count: int, * filters: array{link: array}, * groups: array, * items: array> * } */ public function get($data = []): array { $definition = $this->parseData($data); $newsletterId = $definition->getParameters()['id']; $newsletter = $this->newslettersRepository->findOneById($newsletterId); if (!$newsletter) { throw new NotFoundException(); } $countQuery = $this->getStatsQuery($definition, true); if ($countQuery) { $query = 'SELECT COUNT(*) as cnt FROM ( ' . $countQuery . ' ) t '; /** @var int|null $result */ $result = $this->entityManager->getConnection()->executeQuery($query, [ 'search' => $this->getSearchParameter($definition), ], [ 'search' => ParameterType::STRING, ])->fetchOne(); $count = intval($result); $statsQuery = $this->getStatsQuery($definition); $query = $statsQuery . " ORDER BY {$definition->getSortBy()} {$definition->getSortOrder()} LIMIT :limit OFFSET :offset "; $items = $this ->entityManager ->getConnection() ->executeQuery($query, [ 'limit' => $definition->getLimit(), 'offset' => $definition->getOffset(), 'search' => $this->getSearchParameter($definition), ], [ 'limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER, 'search' => ParameterType::STRING, ]) ->fetchAllAssociative(); } else { $count = 0; $items = []; } return [ 'count' => $count, 'filters' => $this->filters($newsletter), 'groups' => $this->groups($definition, $newsletter), 'items' => $items, ]; } private function getStatsQuery(Listing\ListingDefinition $definition, $count = false, $group = null, $applyConstraints = true) { $filterConstraint = ''; $searchConstraint = ''; $newsletterId = intval($definition->getParameters()['id']); $subscriberTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName(); $opensTable = $this->entityManager->getClassMetadata(StatisticsOpenEntity::class)->getTableName(); $clicksTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName(); $unsubscribeTable = $this->entityManager->getClassMetadata(StatisticsUnsubscribeEntity::class)->getTableName(); $statisticsNewsletterTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName(); if ($applyConstraints) { $filterConstraint = $this->getFilterConstraint($definition); $searchConstraint = $this->getSearchConstraint($definition); } $queries = []; $fields = [ 'opens.subscriber_id', 'opens.newsletter_id', "'" . self::STATUS_OPENED . "' as status", 'opens.created_at', 'subscribers.email', 'subscribers.first_name', 'subscribers.last_name', ]; $queries[self::STATUS_OPENED] = '(SELECT DISTINCT ' . self::getColumnList($fields, $count) . ' ' . 'FROM ' . $opensTable . ' opens ' . 'LEFT JOIN ' . $subscriberTable . ' subscribers ON subscribers.id = opens.subscriber_id ' . "WHERE opens.newsletter_id = '" . $newsletterId . "' " . $searchConstraint . ' ' . "AND opens.user_agent_type = '" . UserAgentEntity::USER_AGENT_TYPE_HUMAN . "') "; $fields = [ 'opens.subscriber_id', 'opens.newsletter_id', "'" . self::STATUS_MACHINE_OPENED . "' as status", 'opens.created_at', 'subscribers.email', 'subscribers.first_name', 'subscribers.last_name', ]; $queries[self::STATUS_MACHINE_OPENED] = '(SELECT DISTINCT ' . self::getColumnList($fields, $count) . ' ' . 'FROM ' . $opensTable . ' opens ' . 'LEFT JOIN ' . $subscriberTable . ' subscribers ON subscribers.id = opens.subscriber_id ' . "WHERE opens.newsletter_id = '" . $newsletterId . "' " . $searchConstraint . ' ' . "AND opens.user_agent_type = '" . UserAgentEntity::USER_AGENT_TYPE_MACHINE . "') "; $fields = [ 'clicks.subscriber_id', 'clicks.newsletter_id', "'" . self::STATUS_CLICKED . "' as status", 'clicks.created_at', 'subscribers.email', 'subscribers.first_name', 'subscribers.last_name', ]; // Avoiding duplicates is managed during the insert process, so we don't need use DISTINCT here $queries[self::STATUS_CLICKED] = '(SELECT ' . self::getColumnList($fields, $count) . ' ' . 'FROM ' . $clicksTable . ' clicks ' . 'LEFT JOIN ' . $subscriberTable . ' subscribers ON subscribers.id = clicks.subscriber_id ' . "WHERE clicks.newsletter_id = '" . $newsletterId . "' " . $searchConstraint . $filterConstraint . ') '; $fields = [ 'unsubscribes.subscriber_id', 'unsubscribes.newsletter_id', "'" . self::STATUS_UNSUBSCRIBED . "' as status", 'unsubscribes.created_at', 'subscribers.email', 'subscribers.first_name', 'subscribers.last_name', ]; $queries[self::STATUS_UNSUBSCRIBED] = '(SELECT DISTINCT ' . self::getColumnList($fields, $count) . ' ' . 'FROM ' . $unsubscribeTable . ' unsubscribes ' . 'LEFT JOIN ' . $subscriberTable . ' subscribers ON subscribers.id = unsubscribes.subscriber_id ' . "WHERE unsubscribes.newsletter_id = '" . $newsletterId . "' " . $searchConstraint . ') '; $fields = [ 'sent.subscriber_id', 'sent.newsletter_id', "'" . self::STATUS_UNOPENED . "' as status", 'sent.sent_at as created_at', 'subscribers.email', 'subscribers.first_name', 'subscribers.last_name', ]; $queries[self::STATUS_UNOPENED] = '(SELECT ' . self::getColumnList($fields, $count) . ' ' . 'FROM ' . $statisticsNewsletterTable . ' sent ' . 'LEFT JOIN ' . $subscriberTable . ' subscribers ON subscribers.id = sent.subscriber_id ' . 'LEFT JOIN ' . $opensTable . ' opens ON sent.subscriber_id = opens.subscriber_id ' . ' AND opens.newsletter_id = sent.newsletter_id ' . "WHERE sent.newsletter_id = '" . $newsletterId . "' " . ' AND opens.id IS NULL ' . $searchConstraint . ') '; $group = $group ?: $definition->getGroup(); if (isset($queries[$group])) { $statsQuery = $queries[$group]; } else { $statsQuery = join( ' UNION ALL ', [ $queries[self::STATUS_OPENED], $queries[self::STATUS_MACHINE_OPENED], $queries[self::STATUS_CLICKED], $queries[self::STATUS_UNSUBSCRIBED], ] ); } return $statsQuery; } private function getFilterConstraint(Listing\ListingDefinition $definition): string { // Filter by link clicked $linkConstraint = ''; $filters = $definition->getFilters(); if (!empty($filters['link'])) { $link = $this->newsletterLinkRepository->findOneById((int)$filters['link']); if ($link instanceof NewsletterLinkEntity) { $linkConstraint = " AND clicks.link_id = '" . $link->getId() . "'"; } } return $linkConstraint; } private function getSearchConstraint(Listing\ListingDefinition $definition) { // Search recipients if (empty($definition->getSearch())) { return ''; } $qb = $this->entityManager->getConnection()->createQueryBuilder(); $qb ->addSelect('id') ->from($this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName()) ->orWhere($qb->expr()->like('email', ':search')) ->orWhere($qb->expr()->like('first_name', ':search')) ->orWhere($qb->expr()->like('last_name', ':search')); $subscriberSearchQuery = $qb->getSQL(); $subscribersConstraint = ' AND subscribers.id IN (' . $subscriberSearchQuery . ') '; return $subscribersConstraint; } private function getSearchParameter(Listing\ListingDefinition $definition): ?string { if (empty($definition->getSearch())) { return null; } $search = trim($definition->getSearch()); $search = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $search); // escape for 'LIKE' return '%' . $search . '%'; } /** * @param array $fields * @param bool $count * * @return string */ private static function getColumnList(array $fields, bool $count = false): string { // Select with DISTINCT on subscriber_id and newsletter_id to avoid duplicates // because due to race condition we can have multiple records for the same subscriber_id and newsletter_id return $count ? "{$fields[0]}, {$fields[1]}" : join(', ', $fields); } /** * @param NewsletterEntity $newsletter * * @return array{link: array} */ private function filters(NewsletterEntity $newsletter): array { $clicks = $this->statisticsClicksRepository->getClickedLinksForFilter($newsletter); $linkList = []; $linkList[] = [ 'label' => __('Filter by link clicked', 'mailpoet-premium'), 'value' => '', ]; foreach ($clicks as $link) { $label = sprintf( '%s (%s)', $link['url'], number_format($link['cnt']) ); $linkList[] = [ 'label' => $label, 'value' => $link['link_id'], ]; } return [ 'link' => $linkList, ]; } /** * @param Listing\ListingDefinition $definition * @param NewsletterEntity $newsletter * * @return array] */ private function groups(Listing\ListingDefinition $definition, NewsletterEntity $newsletter): array { $groups = [ [ 'name' => self::STATUS_CLICKED, 'label' => _x('Clicked', 'Subscriber engagement filter - filter those who clicked on a newsletter link', 'mailpoet-premium'), 'count' => $this->fetchStatsCount($definition, self::STATUS_CLICKED), ], [ 'name' => self::STATUS_OPENED, 'label' => _x('Opened', 'Subscriber engagement filter - filter those who opened a newsletter', 'mailpoet-premium'), 'count' => $this->fetchStatsCount($definition, self::STATUS_OPENED), ], [ 'name' => self::STATUS_MACHINE_OPENED, 'label' => _x('Machine-opened', 'Subscriber engagement filter - shows machine-opens for a given newsletter', 'mailpoet-premium'), 'count' => $this->fetchStatsCount($definition, self::STATUS_MACHINE_OPENED), ], [ 'name' => self::STATUS_UNSUBSCRIBED, 'label' => _x('Unsubscribed', 'Subscriber engagement filter - filter those who unsubscribed from a newsletter', 'mailpoet-premium'), 'count' => $this->fetchStatsCount($definition, self::STATUS_UNSUBSCRIBED), ], ]; array_unshift( $groups, [ 'name' => 'all', 'label' => _x('All engaged', 'Subscriber engagement filter - filter those who performed any action (e.g., clicked, opened, unsubscribed)', 'mailpoet-premium'), 'count' => array_sum(array_column($groups, 'count')), ] ); $groups[] = [ 'name' => self::STATUS_UNOPENED, 'label' => _x('Unopened', 'Subscriber engagement filter - filter those who did not open a newsletter', 'mailpoet-premium'), 'count' => $this->fetchStatsCount($definition, self::STATUS_UNOPENED), ]; return $groups; } private function fetchStatsCount(Listing\ListingDefinition $definition, string $group): int { $subQuery = $this->getStatsQuery($definition, true, $group, false); $query = ' SELECT COUNT(*) as cnt FROM ( ' . $subQuery . ' ) t '; /** @var int|null $result */ $result = $this->entityManager->getConnection()->executeQuery($query)->fetchOne(); return intval($result); } }