Add calculated fields to API 4 for overall mailing stats
authorlarssandergreen <lars@wildsight.ca>
Sat, 1 Jul 2023 17:31:31 +0000 (11:31 -0600)
committerlarssandergreen <lars@wildsight.ca>
Sat, 1 Jul 2023 17:31:31 +0000 (11:31 -0600)
Civi/Api4/Service/Spec/Provider/MailingGetSpecProvider.php [new file with mode: 0644]
tests/phpunit/api/v4/Entity/MailingEvent.php [new file with mode: 0644]

diff --git a/Civi/Api4/Service/Spec/Provider/MailingGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/MailingGetSpecProvider.php
new file mode 100644 (file)
index 0000000..0249de0
--- /dev/null
@@ -0,0 +1,255 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+/**
+ * @service
+ * @internal
+ */
+class MailingGetSpecProvider extends \Civi\Core\Service\AutoService implements Generic\SpecProviderInterface {
+
+  /**
+   * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+   *
+   * @throws \CRM_Core_Exception
+   */
+  public function modifySpec(RequestSpec $spec): void {
+    $field = new FieldSpec('stats_intended_recipients', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Intended Recipients'))
+      ->setDescription(ts('Total emails sent'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countIntendedRecipients']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_successful', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Succesful Deliveries'))
+      ->setDescription(ts('Total emails delivered minus bounces'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countSuccessfulDeliveries']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_opens_total', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Total Opens'))
+      ->setDescription(ts('Total tracked mailing opens'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_opens_unique', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Unique Opens'))
+      ->setDescription(ts('Total unique tracked mailing opens'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_clicks_total', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Total Clicks'))
+      ->setDescription(ts('Total mailing clicks'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_clicks_unique', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Unique Clicks'))
+      ->setDescription(ts('Total unique mailing clicks'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_bounces', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Bounces'))
+      ->setDescription(ts('Total mailing bounces'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_unsubscribes', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Unsubscribes'))
+      ->setDescription(ts('Total mailing unsubscribes'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_optouts', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Opt Outs'))
+      ->setDescription(ts('Total mailing opt outs'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_forwards', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Forwards'))
+      ->setDescription(ts('Total mailing forwards'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+
+    $field = new FieldSpec('stats_replies', 'Mailing', 'Integer');
+    $field->setLabel(ts('Stats: Replies'))
+      ->setDescription(ts('Total mailing replies'))
+      ->setColumnName('id')
+      ->setReadonly(TRUE)
+      ->setSqlRenderer([__CLASS__, 'countMailingEvents']);
+    $spec->addFieldSpec($field);
+  }
+
+  /**
+   * @param string $entity
+   * @param string $action
+   *
+   * @return bool
+   */
+  public function applies($entity, $action): bool {
+    return $entity === 'Mailing' && $action === 'get';
+  }
+
+  /**
+   * Generate SQL for counting mailing events
+   *
+   * @return string
+   */
+  public static function countMailingEvents(array $field): string {
+    $unsubscribeType = $count = NULL;
+    $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+    $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+    $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+
+    switch ($field['name']) {
+      case 'stats_opens_total':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventOpened::getTableName();
+        break;
+
+      case 'stats_opens_unique':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventOpened::getTableName();
+        $count = "DISTINCT $tableName.event_queue_id";
+        break;
+
+      case 'stats_clicks_total':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventTrackableURLOpen::getTableName();
+        break;
+
+      case 'stats_clicks_unique':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventTrackableURLOpen::getTableName();
+        $count = "DISTINCT $tableName.event_queue_id,$tableName.trackable_url_id";
+        break;
+
+      case 'stats_bounces':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventBounce::getTableName();
+        break;
+
+      case 'stats_unsubscribes':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventUnsubscribe::getTableName();
+        $unsubscribeType = 0;
+        $count = "DISTINCT $tableName.event_queue_id,$tableName.org_unsubscribe";
+        break;
+
+      case 'stats_optouts':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventUnsubscribe::getTableName();
+        $unsubscribeType = 1;
+        $count = "DISTINCT $tableName.event_queue_id,$tableName.org_unsubscribe";
+        break;
+
+      case 'stats_forwards':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventForward::getTableName();
+        break;
+
+      case 'stats_replies':
+        $tableName = \CRM_Mailing_Event_BAO_MailingEventReply::getTableName();
+        break;
+
+    }
+
+    $count = $count ?? "$tableName.event_queue_id";
+    $query = "(
+      SELECT      COUNT($count)
+      FROM        $tableName
+      INNER JOIN  $queue
+              ON  $tableName.event_queue_id = $queue.id
+      INNER JOIN  $job
+              ON  $queue.job_id = $job.id
+      INNER JOIN  $mailing
+              ON  $job.mailing_id = $mailing.id
+              AND $job.is_test = 0
+      WHERE       $mailing.id = {$field['sql_name']}
+      ";
+    if (!is_null($unsubscribeType)) {
+      $query .= " AND $tableName.org_unsubscribe = $unsubscribeType";
+    }
+    return $query . ")";
+  }
+
+  /**
+   * Generate SQL for counting total intended recipients
+   *
+   * @return string
+   */
+  public static function countIntendedRecipients(array $field): string {
+    $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+    $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+    $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+
+    return "(
+      SELECT      COUNT($queue.id)
+      FROM        $queue
+      INNER JOIN  $job
+              ON  $queue.job_id = $job.id
+      INNER JOIN  $mailing
+              ON  $job.mailing_id = $mailing.id
+              AND $job.is_test = 0
+      WHERE       $mailing.id = {$field['sql_name']}
+      )";
+  }
+
+  /**
+   * Generate SQL for counting total successful deliveries
+   *
+   * @return string
+   */
+  public static function countSuccessfulDeliveries(array $field): string {
+    $delivered = \CRM_Mailing_Event_BAO_MailingEventDelivered::getTableName();
+    $bounce = \CRM_Mailing_Event_BAO_MailingEventBounce::getTableName();
+    $queue = \CRM_Mailing_Event_BAO_MailingEventQueue::getTableName();
+    $mailing = \CRM_Mailing_BAO_Mailing::getTableName();
+    $job = \CRM_Mailing_BAO_MailingJob::getTableName();
+
+    return "(
+      SELECT      COUNT($delivered.id)
+      FROM        $delivered
+      INNER JOIN  $queue
+              ON  $delivered.event_queue_id = $queue.id
+      LEFT JOIN   $bounce
+              ON  $delivered.event_queue_id = $bounce.event_queue_id
+      INNER JOIN  $job
+              ON  $queue.job_id = $job.id
+              AND $job.is_test = 0
+      INNER JOIN  $mailing
+              ON  $job.mailing_id = $mailing.id
+      WHERE       $bounce.id IS null
+          AND     $mailing.id = {$field['sql_name']}
+      )";
+  }
+
+}
diff --git a/tests/phpunit/api/v4/Entity/MailingEvent.php b/tests/phpunit/api/v4/Entity/MailingEvent.php
new file mode 100644 (file)
index 0000000..296307a
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+namespace api\v4\Entity;
+
+use api\v4\Api4TestBase;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class MailingEventTest extends Api4TestBase implements TransactionalInterface {
+
+  public function testMailingStats() {
+    $cid1 = $this->createTestRecord('Contact')['id'];
+    $cid2 = $this->createTestRecord('Contact')['id'];
+    $eid1 = $this->createTestRecord('Email', ['contact_id' => $cid1])['id'];
+    $eid2 = $this->createTestRecord('Email', ['contact_id' => $cid2])['id'];
+    $mid1 = $this->createTestRecord('Mailing')['id'];
+    $mid2 = $this->createTestRecord('Mailing')['id'];
+    $parentJobIDs = $this->saveTestRecords('MailingJob',
+      [
+        'records' => [
+          ['mailing_id' => $mid1, 'is_test' => 'false'],
+          ['mailing_id' => $mid2, 'is_test' => 'false'],
+        ],
+      ])->column('id');
+
+    $childJobIDs = $this->saveTestRecords('MailingJob',
+      [
+        'records' => [
+          ['mailing_id' => $mid1, 'parent_id' => $parentJobIDs[0], 'job_type' => 'child', 'is_test' => 'false'],
+          ['mailing_id' => $mid2, 'parent_id' => $parentJobIDs[1], 'job_type' => 'child', 'is_test' => 'false'],
+        ],
+      ])->column('id');
+
+    $queueIDs = $this->saveTestRecords('MailingEventQueue',
+      [
+        'records' => [
+          ['job_id' => $childJobIDs[0], 'contact_id' => $cid1, 'email_id' => $eid1],
+          ['job_id' => $childJobIDs[0], 'contact_id' => $cid2, 'email_id' => $eid2],
+          ['job_id' => $childJobIDs[1], 'contact_id' => $cid1, 'email_id' => $eid1],
+          ['job_id' => $childJobIDs[1], 'contact_id' => $cid2, 'email_id' => $eid2],
+        ],
+      ])->column('id');
+
+    $onceEachQueue =
+      [
+        'records' => [
+          ['event_queue_id' => $queueIDs[0]],
+          ['event_queue_id' => $queueIDs[1]],
+        ],
+      ];
+
+    $twiceOneQueue =
+      [
+        'records' => [
+          ['event_queue_id' => $queueIDs[0]],
+          ['event_queue_id' => $queueIDs[0]],
+        ],
+      ];
+
+    $this->saveTestRecords('MailingEventDelivered', $onceEachQueue);
+    $this->createTestRecord('MailingEventBounce', ['event_queue_id' => $queueIDs[1]]);
+    $this->saveTestRecords('MailingEventOpened', $twiceOneQueue);
+    $trackableURLIDs = $this->saveTestRecords('MailingTrackableURL',
+      [
+        'records' => [
+          ['mailing_id' => $mid1],
+          ['mailing_id' => $mid1],
+        ],
+      ])->column('id');
+
+    $this->saveTestRecords('MailingEventTrackableURLOpen',
+      [
+        'records' => [
+          ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[0]],
+          ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[1]],
+          ['event_queue_id' => $queueIDs[0], 'trackable_url_id' => $trackableURLIDs[1]],
+          ['event_queue_id' => $queueIDs[1], 'trackable_url_id' => $trackableURLIDs[1]],
+        ],
+      ]);
+
+    $this->saveTestRecords('MailingEventForward', $twiceOneQueue);
+    $this->saveTestRecords('MailingEventReply', $twiceOneQueue);
+    $this->saveTestRecords('MailingEventUnsubscribe',
+      [
+        'records' => [
+          ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 0],
+          ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 0],
+          ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 1],
+          ['event_queue_id' => $queueIDs[0], 'org_unsubscribe' => 1],
+          ['event_queue_id' => $queueIDs[1], 'org_unsubscribe' => 1],
+        ],
+      ]);
+
+    $mailings = \Civi\Api4\Mailing::get(FALSE)
+      ->addSelect('stats_intended_recipients', 'stats_successful', 'stats_opens_total', 'stats_opens_unique', 'stats_clicks_total',
+        'stats_clicks_unique', 'stats_bounces', 'stats_unsubscribes', 'stats_optouts', 'stats_forwards', 'stats_replies')
+      ->addWhere('id', 'IN', [$mid1, $mid2])
+      ->addOrderBy('id', 'ASC')
+      ->execute();
+
+    $this->assertEquals(2, $mailings[0]['stats_intended_recipients']);
+    $this->assertEquals(2, $mailings[1]['stats_intended_recipients']);
+    $this->assertEquals(1, $mailings[0]['stats_bounces']);
+    $this->assertEquals(0, $mailings[1]['stats_bounces']);
+    $this->assertEquals(1, $mailings[0]['stats_successful']);
+    $this->assertEquals(0, $mailings[1]['stats_successful']);
+    $this->assertEquals(2, $mailings[0]['stats_opens_total']);
+    $this->assertEquals(0, $mailings[1]['stats_opens_total']);
+    $this->assertEquals(1, $mailings[0]['stats_opens_unique']);
+    $this->assertEquals(0, $mailings[1]['stats_opens_unique']);
+    $this->assertEquals(4, $mailings[0]['stats_clicks_total']);
+    $this->assertEquals(0, $mailings[1]['stats_clicks_total']);
+    $this->assertEquals(3, $mailings[0]['stats_clicks_unique']);
+    $this->assertEquals(0, $mailings[1]['stats_clicks_unique']);
+    $this->assertEquals(2, $mailings[0]['stats_forwards']);
+    $this->assertEquals(0, $mailings[1]['stats_forwards']);
+    $this->assertEquals(2, $mailings[0]['stats_replies']);
+    $this->assertEquals(0, $mailings[1]['stats_replies']);
+    $this->assertEquals(1, $mailings[0]['stats_unsubscribes']);
+    $this->assertEquals(0, $mailings[1]['stats_unsubscribes']);
+    $this->assertEquals(2, $mailings[0]['stats_optouts']);
+    $this->assertEquals(0, $mailings[1]['stats_optouts']);
+  }
+
+}