| 1 |
<?php |
| 2 |
|
| 3 |
namespace gVectors\News\Services; |
| 4 |
|
| 5 |
use gVectors\News\Config; |
| 6 |
|
| 7 |
// Exit if accessed directly |
| 8 |
if( ! defined( 'ABSPATH' ) ) exit; |
| 9 |
|
| 10 |
/** |
| 11 |
* Central preference matrix: channel × category, at two scopes. |
| 12 |
* |
| 13 |
* Channels: |
| 14 |
* - notices : dashboard admin notices |
| 15 |
* - emails : emails sent via wp_mail |
| 16 |
* |
| 17 |
* Categories: |
| 18 |
* - expiry_reminder (emails only — transactional) |
| 19 |
* - renewal_offer (emails only — unused personal renewal |
| 20 |
* discount reminders, unsubscribable separately from expiry warnings) |
| 21 |
* - new_addon, new_feature, announcement (news content types) |
| 22 |
* - addons_installed / addons_not_installed (relevance filter — applies only |
| 23 |
* to news items targeted at a specific addon via plugin_slug: news about an |
| 24 |
* addon the site has installed vs news about addons it doesn't have) |
| 25 |
* |
| 26 |
* Scopes: |
| 27 |
* - site : one shared option, applies to everyone (option: site prefs) |
| 28 |
* - user : each administrator's own choices (user meta), incl. a master |
| 29 |
* email unsubscribe |
| 30 |
* |
| 31 |
* A news item reaches an admin through a channel only when BOTH scopes allow |
| 32 |
* its content category AND (for addon-targeted items) its relevance category. |
| 33 |
* Everything defaults to enabled — only an explicit false disables. |
| 34 |
*/ |
| 35 |
class PrefsService { |
| 36 |
|
| 37 |
public const CHANNEL_NOTICES = 'notices'; |
| 38 |
public const CHANNEL_EMAILS = 'emails'; |
| 39 |
public const CHANNELS = [ self::CHANNEL_NOTICES, self::CHANNEL_EMAILS ]; |
| 40 |
|
| 41 |
/** News content categories (map 1:1 from news item types). */ |
| 42 |
public const NEWS_CATEGORIES = [ 'new_addon', 'new_feature', 'new_version', 'discount', 'announcement' ]; |
| 43 |
|
| 44 |
/** Relevance filter for addon-targeted news (items with a plugin_slug). */ |
| 45 |
public const RELEVANCE_CATEGORIES = [ 'addons_installed', 'addons_not_installed' ]; |
| 46 |
|
| 47 |
/** Categories that only exist on the emails channel. */ |
| 48 |
public const EMAIL_ONLY_CATEGORIES = [ 'expiry_reminder', 'renewal_offer', 'abandoned_checkout', 'recommendations' ]; |
| 49 |
|
| 50 |
private $config; |
| 51 |
|
| 52 |
public function __construct( Config $config ) { |
| 53 |
$this->config = $config; |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* All category keys available on a channel. |
| 58 |
*/ |
| 59 |
public static function channel_categories( string $channel ): array { |
| 60 |
$categories = array_merge( self::NEWS_CATEGORIES, self::RELEVANCE_CATEGORIES ); |
| 61 |
if( $channel === self::CHANNEL_EMAILS ) { |
| 62 |
$categories = array_merge( self::EMAIL_ONLY_CATEGORIES, $categories ); |
| 63 |
} |
| 64 |
|
| 65 |
return $categories; |
| 66 |
} |
| 67 |
|
| 68 |
// ========================================== |
| 69 |
// Per-admin preferences (user meta) |
| 70 |
// ========================================== |
| 71 |
|
| 72 |
/** |
| 73 |
* Shape: ['unsubscribed' => bool, 'notices' => [cat => bool], 'emails' => [cat => bool]] |
| 74 |
* Missing meta or missing keys mean enabled. |
| 75 |
*/ |
| 76 |
public function get_user_prefs( int $user_id ): array { |
| 77 |
$raw = get_user_meta( $user_id, $this->config->get_user_prefs_meta(), true ); |
| 78 |
$raw = is_array( $raw ) ? $raw : []; |
| 79 |
|
| 80 |
$prefs = [ 'unsubscribed' => ! empty( $raw['unsubscribed'] ) ]; |
| 81 |
foreach( self::CHANNELS as $channel ) { |
| 82 |
foreach( self::channel_categories( $channel ) as $category ) { |
| 83 |
$prefs[ $channel ][ $category ] = ! isset( $raw[ $channel ][ $category ] ) || ! empty( $raw[ $channel ][ $category ] ); |
| 84 |
} |
| 85 |
} |
| 86 |
|
| 87 |
return $prefs; |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* @param array $enabled_by_channel ['notices' => ['new_addon', ...], 'emails' => [...]] — enabled category keys per channel |
| 92 |
*/ |
| 93 |
public function save_user_prefs( int $user_id, bool $unsubscribed, array $enabled_by_channel ): void { |
| 94 |
update_user_meta( |
| 95 |
$user_id, |
| 96 |
$this->config->get_user_prefs_meta(), |
| 97 |
[ 'unsubscribed' => $unsubscribed ] + $this->normalize_matrix( $enabled_by_channel ) |
| 98 |
); |
| 99 |
} |
| 100 |
|
| 101 |
// ========================================== |
| 102 |
// Site-wide preferences (shared option) |
| 103 |
// ========================================== |
| 104 |
|
| 105 |
/** |
| 106 |
* Shape: ['notices' => [cat => bool], 'emails' => [cat => bool]] — missing keys mean enabled. |
| 107 |
*/ |
| 108 |
public function get_site_prefs(): array { |
| 109 |
$raw = get_option( $this->config->get_site_prefs_option(), [] ); |
| 110 |
$raw = is_array( $raw ) ? $raw : []; |
| 111 |
|
| 112 |
$prefs = []; |
| 113 |
foreach( self::CHANNELS as $channel ) { |
| 114 |
foreach( self::channel_categories( $channel ) as $category ) { |
| 115 |
$prefs[ $channel ][ $category ] = ! isset( $raw[ $channel ][ $category ] ) || ! empty( $raw[ $channel ][ $category ] ); |
| 116 |
} |
| 117 |
} |
| 118 |
|
| 119 |
return $prefs; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* @param array $enabled_by_channel ['notices' => [enabled keys], 'emails' => [enabled keys]] |
| 124 |
*/ |
| 125 |
public function save_site_prefs( array $enabled_by_channel ): void { |
| 126 |
update_option( $this->config->get_site_prefs_option(), $this->normalize_matrix( $enabled_by_channel ) ); |
| 127 |
} |
| 128 |
|
| 129 |
private function normalize_matrix( array $enabled_by_channel ): array { |
| 130 |
$matrix = []; |
| 131 |
foreach( self::CHANNELS as $channel ) { |
| 132 |
$enabled = isset( $enabled_by_channel[ $channel ] ) && is_array( $enabled_by_channel[ $channel ] ) |
| 133 |
? $enabled_by_channel[ $channel ] |
| 134 |
: []; |
| 135 |
foreach( self::channel_categories( $channel ) as $category ) { |
| 136 |
$matrix[ $channel ][ $category ] = in_array( $category, $enabled, true ); |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
return $matrix; |
| 141 |
} |
| 142 |
|
| 143 |
// ========================================== |
| 144 |
// Decision helpers |
| 145 |
// ========================================== |
| 146 |
|
| 147 |
public function site_allows( string $channel, string $category ): bool { |
| 148 |
$site = $this->get_site_prefs(); |
| 149 |
|
| 150 |
return ! empty( $site[ $channel ][ $category ] ); |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Per-admin check. On the emails channel a master unsubscribe blocks everything. |
| 155 |
*/ |
| 156 |
public function user_allows( int $user_id, string $channel, string $category ): bool { |
| 157 |
$prefs = $this->get_user_prefs( $user_id ); |
| 158 |
if( $channel === self::CHANNEL_EMAILS && $prefs['unsubscribed'] ) return false; |
| 159 |
|
| 160 |
return ! empty( $prefs[ $channel ][ $category ] ); |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Combined site + user check — the only gate senders/renderers should use. |
| 165 |
*/ |
| 166 |
public function allows( int $user_id, string $channel, string $category ): bool { |
| 167 |
return $this->site_allows( $channel, $category ) && $this->user_allows( $user_id, $channel, $category ); |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Full decision for one news item on one channel for one admin: |
| 172 |
* content category must be allowed, and for addon-targeted items the |
| 173 |
* relevance category (installed / not installed) must be allowed too. |
| 174 |
*/ |
| 175 |
public function news_item_allowed( array $item, string $channel, int $user_id ): bool { |
| 176 |
$category = in_array( $item['type'] ?? '', self::NEWS_CATEGORIES, true ) ? $item['type'] : 'announcement'; |
| 177 |
if( ! $this->allows( $user_id, $channel, $category ) ) return false; |
| 178 |
|
| 179 |
if( ! empty( $item['plugin_slug'] ) ) { |
| 180 |
$relevance = $this->is_addon_installed( (string) $item['plugin_slug'] ) ? 'addons_installed' : 'addons_not_installed'; |
| 181 |
if( ! $this->allows( $user_id, $channel, $relevance ) ) return false; |
| 182 |
} |
| 183 |
|
| 184 |
return true; |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Is an addon with this plugin slug installed on the site (any activation state)? |
| 189 |
* Matched by plugin directory name; result cached per request. |
| 190 |
*/ |
| 191 |
public function is_addon_installed( string $slug ): bool { |
| 192 |
static $installed = null; |
| 193 |
if( $installed === null ) { |
| 194 |
if( ! function_exists( 'get_plugins' ) ) { |
| 195 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 196 |
} |
| 197 |
$installed = []; |
| 198 |
foreach( array_keys( get_plugins() ) as $plugin_file ) { |
| 199 |
$installed[ dirname( $plugin_file ) ] = true; |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
return isset( $installed[ $slug ] ); |
| 204 |
} |
| 205 |
} |
| 206 |
|