Files
WooNooW/includes/Core/Campaigns/CampaignManager.php
Dwindi Ramadhana 65dd847a66 feat: add Newsletter Campaigns backend infrastructure
- Add CampaignManager.php with CPT registration, CRUD, batch sending
- Add CampaignsController.php with 8 REST endpoints (list, create, get, update, delete, send, test, preview)
- Register newsletter_campaign event in EventRegistry for email template
- Initialize CampaignManager in Bootstrap.php
- Register routes in Routes.php
2025-12-31 14:58:57 +07:00

484 lines
15 KiB
PHP

<?php
/**
* Campaign Manager
*
* Manages newsletter campaign CRUD operations and sending
*
* @package WooNooW\Core\Campaigns
*/
namespace WooNooW\Core\Campaigns;
if (!defined('ABSPATH')) exit;
class CampaignManager {
const POST_TYPE = 'wnw_campaign';
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
private static $instance = null;
/**
* Get instance
*/
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Initialize
*/
public static function init() {
add_action('init', [__CLASS__, 'register_post_type']);
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
}
/**
* Register campaign post type
*/
public static function register_post_type() {
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('Campaigns', 'woonoow'),
'singular_name' => __('Campaign', 'woonoow'),
],
'public' => false,
'show_ui' => false,
'show_in_rest' => false,
'supports' => ['title'],
'capability_type' => 'post',
'map_meta_cap' => true,
]);
}
/**
* Create a new campaign
*
* @param array $data Campaign data
* @return int|WP_Error Campaign ID or error
*/
public static function create($data) {
$post_data = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
];
$campaign_id = wp_insert_post($post_data, true);
if (is_wp_error($campaign_id)) {
return $campaign_id;
}
// Save meta fields
self::update_meta($campaign_id, $data);
return $campaign_id;
}
/**
* Update campaign
*
* @param int $campaign_id Campaign ID
* @param array $data Campaign data
* @return bool|WP_Error
*/
public static function update($campaign_id, $data) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
}
// Update title if provided
if (isset($data['title'])) {
wp_update_post([
'ID' => $campaign_id,
'post_title' => sanitize_text_field($data['title']),
]);
}
// Update meta fields
self::update_meta($campaign_id, $data);
return true;
}
/**
* Update campaign meta
*
* @param int $campaign_id
* @param array $data
*/
private static function update_meta($campaign_id, $data) {
$meta_fields = [
'subject' => '_wnw_subject',
'content' => '_wnw_content',
'status' => '_wnw_status',
'scheduled_at' => '_wnw_scheduled_at',
];
foreach ($meta_fields as $key => $meta_key) {
if (isset($data[$key])) {
$value = $data[$key];
// Sanitize based on field type
if ($key === 'content') {
$value = wp_kses_post($value);
} elseif ($key === 'scheduled_at') {
$value = sanitize_text_field($value);
} elseif ($key === 'status') {
$allowed = ['draft', 'scheduled', 'sending', 'sent', 'failed'];
$value = in_array($value, $allowed) ? $value : 'draft';
} else {
$value = sanitize_text_field($value);
}
update_post_meta($campaign_id, $meta_key, $value);
}
}
// Set default status if not provided
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
update_post_meta($campaign_id, '_wnw_status', 'draft');
}
}
/**
* Get campaign by ID
*
* @param int $campaign_id
* @return array|null
*/
public static function get($campaign_id) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return null;
}
return self::format_campaign($post);
}
/**
* Get all campaigns
*
* @param array $args Query args
* @return array
*/
public static function get_all($args = []) {
$defaults = [
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'DESC',
];
$query_args = wp_parse_args($args, $defaults);
$query_args['post_type'] = self::POST_TYPE; // Force post type
$posts = get_posts($query_args);
return array_map([__CLASS__, 'format_campaign'], $posts);
}
/**
* Format campaign post to array
*
* @param WP_Post $post
* @return array
*/
private static function format_campaign($post) {
return [
'id' => $post->ID,
'title' => $post->post_title,
'subject' => get_post_meta($post->ID, '_wnw_subject', true),
'content' => get_post_meta($post->ID, '_wnw_content', true),
'status' => get_post_meta($post->ID, '_wnw_status', true) ?: 'draft',
'scheduled_at' => get_post_meta($post->ID, '_wnw_scheduled_at', true),
'sent_at' => get_post_meta($post->ID, '_wnw_sent_at', true),
'recipient_count' => (int) get_post_meta($post->ID, '_wnw_recipient_count', true),
'sent_count' => (int) get_post_meta($post->ID, '_wnw_sent_count', true),
'failed_count' => (int) get_post_meta($post->ID, '_wnw_failed_count', true),
'created_at' => $post->post_date,
'updated_at' => $post->post_modified,
];
}
/**
* Delete campaign
*
* @param int $campaign_id
* @return bool
*/
public static function delete($campaign_id) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return false;
}
return wp_delete_post($campaign_id, true) !== false;
}
/**
* Send campaign
*
* @param int $campaign_id
* @return array Result with sent/failed counts
*/
public static function send($campaign_id) {
$campaign = self::get($campaign_id);
if (!$campaign) {
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
}
if ($campaign['status'] === 'sent') {
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
}
// Get subscribers
$subscribers = self::get_subscribers();
if (empty($subscribers)) {
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
}
// Update status to sending
update_post_meta($campaign_id, '_wnw_status', 'sending');
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
$sent = 0;
$failed = 0;
// Get email template
$template = self::render_campaign_email($campaign);
// Send in batches
$batch_size = 50;
$batches = array_chunk($subscribers, $batch_size);
foreach ($batches as $batch) {
foreach ($batch as $subscriber) {
$email = $subscriber['email'];
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
// Send email
$result = wp_mail(
$email,
$template['subject'],
$body,
['Content-Type: text/html; charset=UTF-8']
);
if ($result) {
$sent++;
} else {
$failed++;
}
}
// Small delay between batches
if (count($batches) > 1) {
sleep(2);
}
}
// Update campaign stats
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
return [
'success' => true,
'sent' => $sent,
'failed' => $failed,
'total' => count($subscribers),
];
}
/**
* Send test email
*
* @param int $campaign_id
* @param string $email Test email address
* @return bool
*/
public static function send_test($campaign_id, $email) {
$campaign = self::get($campaign_id);
if (!$campaign) {
return false;
}
$template = self::render_campaign_email($campaign);
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', '#', $body);
return wp_mail(
$email,
'[TEST] ' . $template['subject'],
$body,
['Content-Type: text/html; charset=UTF-8']
);
}
/**
* Render campaign email using EmailRenderer
*
* @param array $campaign
* @return array ['subject' => string, 'body' => string]
*/
private static function render_campaign_email($campaign) {
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
// Get the campaign email template
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
// Fallback if no template configured
if (!$template) {
$subject = $campaign['subject'] ?: $campaign['title'];
$body = $campaign['content'];
} else {
$subject = $template['subject'] ?: $campaign['subject'];
// Replace {content} with campaign content
$body = str_replace('{content}', $campaign['content'], $template['body']);
// Replace {campaign_title}
$body = str_replace('{campaign_title}', $campaign['title'], $body);
}
// Replace common variables
$site_name = get_bloginfo('name');
$site_url = home_url();
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
$body = str_replace('{site_url}', $site_url, $body);
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
$body = str_replace('{current_year}', date('Y'), $body);
// Render through email design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
$body = $renderer->render_html($design_path, $body, $subject, [
'site_name' => $site_name,
'site_url' => $site_url,
]);
}
return [
'subject' => $subject,
'body' => $body,
];
}
/**
* Get subscribers
*
* @return array
*/
private static function get_subscribers() {
// Check if using custom table
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
if ($use_table && self::has_subscribers_table()) {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_results(
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
ARRAY_A
);
}
// Use wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return array_filter($subscribers, function($sub) {
return ($sub['status'] ?? 'active') === 'active';
});
}
/**
* Check if subscribers table exists
*
* @return bool
*/
private static function has_subscribers_table() {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
}
/**
* Get unsubscribe URL
*
* @param string $email
* @return string
*/
private static function get_unsubscribe_url($email) {
$token = wp_hash($email . 'woonoow_unsubscribe');
return add_query_arg([
'woonoow_unsubscribe' => 1,
'email' => urlencode($email),
'token' => $token,
], home_url());
}
/**
* Process scheduled campaigns (WP-Cron)
*/
public static function process_scheduled_campaigns() {
// Only if scheduling is enabled
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
return;
}
$campaigns = self::get_all([
'meta_query' => [
[
'key' => '_wnw_status',
'value' => 'scheduled',
],
[
'key' => '_wnw_scheduled_at',
'value' => current_time('mysql'),
'compare' => '<=',
'type' => 'DATETIME',
],
],
]);
foreach ($campaigns as $campaign) {
self::send($campaign['id']);
}
}
/**
* Enable scheduling (registers cron)
*/
public static function enable_scheduling() {
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
}
}
/**
* Disable scheduling (clears cron)
*/
public static function disable_scheduling() {
wp_clear_scheduled_hook(self::CRON_HOOK);
}
}