v2.2.0
This commit is contained in:
parent
0a42e00299
commit
0ece5f3a6d
16 changed files with 2342 additions and 785 deletions
427
includes/class-statistics.php
Normal file
427
includes/class-statistics.php
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue