robotstxt-smtp-newsletter/includes/class-statistics.php
2026-02-18 15:24:59 +00:00

427 lines
11 KiB
PHP

<?php
/**
* Email Statistics Management Class
*
* Manages campaign performance metrics and historical data.
*
* @package ROBOTSTXT_SMTP_Newsletter
* @since 2.2.0
*/
namespace Robotstxt_SMTP_Newsletter;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Statistics management class.
*
* Handles aggregation, persistence, and querying of email campaign metrics.
*/
class Statistics {
/**
* Table name (without prefix).
*
* @var string
*/
private const TABLE_NAME = 'robotstxt_smtp_newsletter_stats';
/**
* Database version for migrations.
*
* @var string
*/
private const DB_VERSION = '1.1';
/**
* Option name for database version tracking.
*
* @var string
*/
private const DB_VERSION_OPTION = 'robotstxt_smtp_newsletter_stats_db_version';
/**
* Singleton instance.
*
* @var Statistics|null
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return Statistics
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*
* Ensures table is created on instantiation.
*/
private function __construct() {
$this->maybe_create_table();
}
/**
* Get full table name with WordPress prefix.
*
* @return string
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or upgrade table if needed.
*
* @return void
*/
private function maybe_create_table() {
$stored_version = get_option( self::DB_VERSION_OPTION, '0' );
if ( version_compare( $stored_version, self::DB_VERSION, '>=' ) ) {
return;
}
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
newsletter_id BIGINT UNSIGNED NULL,
campaign_name VARCHAR(255) NULL,
provider VARCHAR(32) NOT NULL,
total_emails INT UNSIGNED NOT NULL DEFAULT 0,
sent_emails INT UNSIGNED NOT NULL DEFAULT 0,
failed_emails INT UNSIGNED NOT NULL DEFAULT 0,
started_at DATETIME(6) NULL,
finished_at DATETIME(6) NULL,
duration_seconds FLOAT NULL,
emails_per_hour FLOAT NULL,
emails_per_minute FLOAT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_newsletter_unique (newsletter_id),
KEY idx_created (created_at),
KEY idx_finished (finished_at)
) {$charset_collate} ENGINE=InnoDB;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
}
/**
* Start tracking a new campaign.
*
* @param int $newsletter_id Newsletter ID.
* @param string $campaign_name Campaign display name.
* @param int $total_emails Total emails to send.
*
* @return int|false Inserted stat ID on success, false on failure.
*/
public function start_campaign( $newsletter_id, $campaign_name, $total_emails ) {
global $wpdb;
$table = self::get_table_name();
$data = array(
'newsletter_id' => $newsletter_id,
'campaign_name' => $campaign_name,
'provider' => $this->get_provider_name(),
'total_emails' => absint( $total_emails ),
'started_at' => current_time( 'mysql', true ),
);
$format = array( '%d', '%s', '%s', '%d', '%s' );
$result = $wpdb->insert( $table, $data, $format );
return $result ? $wpdb->insert_id : false;
}
/**
* Update campaign progress.
*
* @param int $stat_id Stat ID.
* @param int $sent Number of emails sent.
* @param int $failed Number of emails failed.
*
* @return bool Success status.
*/
public function update_campaign_progress( $stat_id, $sent, $failed ) {
global $wpdb;
$table = self::get_table_name();
$data = array(
'sent_emails' => absint( $sent ),
'failed_emails' => absint( $failed ),
);
$format = array( '%d', '%d' );
return false !== $wpdb->update( $table, $data, array( 'id' => $stat_id ), $format, array( '%d' ) );
}
/**
* Mark campaign as finished and calculate final metrics.
*
* @param int $stat_id Stat ID.
*
* @return bool Success status.
*/
public function finish_campaign( $stat_id ) {
global $wpdb;
$table = self::get_table_name();
// Get campaign data.
$campaign = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
"SELECT started_at, sent_emails FROM {$table} WHERE id = %d",
$stat_id
)
);
if ( ! $campaign || ! $campaign->started_at ) {
return false;
}
$finished_at = current_time( 'mysql', true );
$start_time = strtotime( $campaign->started_at );
$finish_time = strtotime( $finished_at );
$duration = max( 1, $finish_time - $start_time );
$emails_per_hour = ( $campaign->sent_emails / $duration ) * 3600;
$emails_per_minute = ( $campaign->sent_emails / $duration ) * 60;
$data = array(
'finished_at' => $finished_at,
'duration_seconds' => $duration,
'emails_per_hour' => $emails_per_hour,
'emails_per_minute' => $emails_per_minute,
);
$format = array( '%s', '%f', '%f', '%f' );
return false !== $wpdb->update( $table, $data, array( 'id' => $stat_id ), $format, array( '%d' ) );
}
/**
* Synchronize statistics from queue table.
*
* Aggregates queue data and updates/creates statistics records.
* This preserves metrics even after queue cleanup.
*
* @param int|null $newsletter_id Optional newsletter ID to sync (null = all).
*
* @return void
*/
public function sync_from_queue( $newsletter_id = null ) {
global $wpdb;
$queue_table = Queue::get_table_name();
$stats_table = self::get_table_name();
$where = $newsletter_id ? $wpdb->prepare( 'WHERE newsletter_id = %d', $newsletter_id ) : 'WHERE newsletter_id IS NOT NULL';
$campaigns = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WHERE clause is safe.
"SELECT
newsletter_id,
MAX(campaign_name) as campaign_name,
MAX(provider) as provider,
MIN(created_at) as started_at,
MAX(sent_at) as finished_at,
COUNT(*) as total_emails,
SUM(CASE WHEN sent_at IS NOT NULL THEN 1 ELSE 0 END) as sent_emails,
SUM(CASE WHEN attempts >= 3 AND sent_at IS NULL THEN 1 ELSE 0 END) as failed_emails
FROM {$queue_table}
{$where}
GROUP BY newsletter_id"
);
foreach ( $campaigns as $campaign ) {
$duration = null;
$per_hour = null;
$per_minute = null;
if ( $campaign->finished_at && $campaign->started_at ) {
$start = strtotime( $campaign->started_at );
$finish = strtotime( $campaign->finished_at );
$duration = max( 1, $finish - $start );
if ( $duration > 0 && $campaign->sent_emails > 0 ) {
$per_hour = ( $campaign->sent_emails / $duration ) * 3600;
$per_minute = ( $campaign->sent_emails / $duration ) * 60;
}
}
// Check if stat record exists (by newsletter_id only).
$existing = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
"SELECT id FROM {$stats_table} WHERE newsletter_id = %d",
$campaign->newsletter_id
)
);
if ( $existing ) {
// Update existing.
$wpdb->update(
$stats_table,
array(
'total_emails' => $campaign->total_emails,
'sent_emails' => $campaign->sent_emails,
'failed_emails' => $campaign->failed_emails,
'started_at' => $campaign->started_at,
'finished_at' => $campaign->finished_at,
'duration_seconds' => $duration,
'emails_per_hour' => $per_hour,
'emails_per_minute' => $per_minute,
),
array( 'id' => $existing ),
array( '%d', '%d', '%d', '%s', '%s', '%f', '%f', '%f' ),
array( '%d' )
);
} else {
// Insert new.
$wpdb->insert(
$stats_table,
array(
'newsletter_id' => $campaign->newsletter_id,
'campaign_name' => $campaign->campaign_name,
'provider' => $campaign->provider,
'total_emails' => $campaign->total_emails,
'sent_emails' => $campaign->sent_emails,
'failed_emails' => $campaign->failed_emails,
'started_at' => $campaign->started_at,
'finished_at' => $campaign->finished_at,
'duration_seconds' => $duration,
'emails_per_hour' => $per_hour,
'emails_per_minute' => $per_minute,
),
array( '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%f', '%f', '%f' )
);
}
}
}
/**
* Get statistics for a specific campaign.
*
* @param int $newsletter_id Newsletter ID.
*
* @return object|null Campaign statistics or null if not found.
*/
public function get_campaign_stats( $newsletter_id ) {
global $wpdb;
$table = self::get_table_name();
return $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
"SELECT * FROM {$table}
WHERE newsletter_id = %d
ORDER BY created_at DESC
LIMIT 1",
$newsletter_id
)
);
}
/**
* Get recent campaigns.
*
* @param int $limit Maximum results.
*
* @return object[] Array of campaign statistics.
*/
public function get_recent_campaigns( $limit = 10 ) {
global $wpdb;
$table = self::get_table_name();
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
"SELECT * FROM {$table}
ORDER BY created_at DESC
LIMIT %d",
$limit
)
);
}
/**
* Get global statistics.
*
* @param int $days Number of days to aggregate.
*
* @return array{total_sent: int, total_failed: int, avg_emails_per_hour: float}
*/
public function get_global_stats( $days = 30 ) {
global $wpdb;
$table = self::get_table_name();
$stats = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe.
"SELECT
SUM(sent_emails) as total_sent,
SUM(failed_emails) as total_failed,
AVG(emails_per_hour) as avg_emails_per_hour
FROM {$table}
WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)",
$days
),
ARRAY_A
);
return array(
'total_sent' => absint( $stats['total_sent'] ?? 0 ),
'total_failed' => absint( $stats['total_failed'] ?? 0 ),
'avg_emails_per_hour' => floatval( $stats['avg_emails_per_hour'] ?? 0 ),
);
}
/**
* Delete statistics for a campaign.
*
* @param int $newsletter_id Newsletter ID.
*
* @return int Number of records deleted.
*/
public function delete_campaign_stats( $newsletter_id ) {
global $wpdb;
$table = self::get_table_name();
$result = $wpdb->delete( $table, array( 'newsletter_id' => $newsletter_id ), array( '%d' ) );
return absint( $result );
}
/**
* Get current provider name.
*
* @return string Provider identifier.
*/
private function get_provider_name() {
if ( ! class_exists( '\Robotstxt_SMTP\Plugin' ) ) {
return 'unknown';
}
$is_ses = \Robotstxt_SMTP\Plugin::is_amazon_ses_integration_active();
return $is_ses ? 'amazonses' : 'postfix';
}
}