| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCommunity\App\Services; |
| 4 |
|
| 5 |
use FluentCommunity\App\Functions\Utility; |
| 6 |
use FluentCommunity\App\Models\NotificationPreference; |
| 7 |
use FluentCommunity\App\Models\Space; |
| 8 |
use FluentCommunity\Database\Migrations\NotificationPrefMigrator; |
| 9 |
use FluentCommunity\Framework\Support\Arr; |
| 10 |
|
| 11 |
/** |
| 12 |
* Read/write layer over a member's notification preferences. |
| 13 |
* |
| 14 |
* The public surface here - flat keys like 'mention_push', getUserPrefs(), |
| 15 |
* willGetNotification(), primeUserPrefs(), filterPushUserIds() - is unchanged. |
| 16 |
* What changed is where the rows live. |
| 17 |
* |
| 18 |
* Preferences used to share fcom_notification_users with notification receipts, |
| 19 |
* separated only by an object_type column and a global scope declared in a |
| 20 |
* boot() closure. The two have opposite lifecycles: receipts are high-churn and |
| 21 |
* pruned at a month, preferences are a handful of permanent rows per member. |
| 22 |
* They now live in fcom_notification_prefs, where the channel is a real column |
| 23 |
* rather than a suffix on a key name, and where the reconcile below cannot reach |
| 24 |
* a notification receipt even if its scoping were removed. |
| 25 |
* |
| 26 |
* NOTIFICATION_EVENTS stays the source of truth for the flat key vocabulary; |
| 27 |
* prefKeyMap() derives the storage cells from it, so registering a channel there |
| 28 |
* is still the only place a new channel has to be declared. |
| 29 |
*/ |
| 30 |
class NotificationPref |
| 31 |
{ |
| 32 |
const NOTIFICATION_EVENTS = [ |
| 33 |
'comment' => ['mail' => 'com_my_post_mail', 'push' => 'com_my_post_push'], |
| 34 |
'reply' => ['mail' => 'reply_my_com_mail', 'push' => 'reply_my_com_push'], |
| 35 |
'mention' => ['mail' => 'mention_mail', 'push' => 'mention_push'], |
| 36 |
'co_comment' => ['push' => 'co_com_push'], |
| 37 |
'digest' => ['mail' => 'digest_mail'] |
| 38 |
]; |
| 39 |
|
| 40 |
/** |
| 41 |
* The digest is the one event whose admin default is named differently from |
| 42 |
* the member's row key. Everything else falls back to NOTIFICATION_EVENTS. |
| 43 |
*/ |
| 44 |
const GLOBAL_KEY_OVERRIDES = [ |
| 45 |
'digest.mail' => 'digest_email_status' |
| 46 |
]; |
| 47 |
|
| 48 |
/** |
| 49 |
* Keys that predate NOTIFICATION_EVENTS and are not part of the event x |
| 50 |
* channel grid: a frequency enum, and the two space-scoped subscriptions. |
| 51 |
* |
| 52 |
* flat key => [channel, event_key, is_object_scoped] |
| 53 |
*/ |
| 54 |
const EXTRA_PREF_KEYS = [ |
| 55 |
'message_email_frequency' => ['mail', 'message_frequency', false], |
| 56 |
'np_by_member_mail' => ['mail', 'np_by_member', true], |
| 57 |
'np_by_admin_mail' => ['mail', 'np_by_admin', true], |
| 58 |
]; |
| 59 |
|
| 60 |
const AGGREGATE_OPTION = 'fluent_community_pref_aggregates'; |
| 61 |
|
| 62 |
const CACHE_PREFIX = 'user_notification_pref_'; |
| 63 |
|
| 64 |
public static function getGlobalPrefs($type = 'mail') |
| 65 |
{ |
| 66 |
if ($type === 'push') { |
| 67 |
$pref = Utility::getPushNotificationSettings(); |
| 68 |
} else { |
| 69 |
$type = 'mail'; |
| 70 |
$pref = Utility::getEmailNotificationSettings(); |
| 71 |
} |
| 72 |
|
| 73 |
$globalKeys = []; |
| 74 |
|
| 75 |
foreach (array_keys(self::NOTIFICATION_EVENTS) as $event) { |
| 76 |
$globalKey = self::getGlobalKeyFor($event, $type); |
| 77 |
|
| 78 |
if ($globalKey) { |
| 79 |
$globalKeys[] = $globalKey; |
| 80 |
} |
| 81 |
} |
| 82 |
|
| 83 |
return array_map(function ($value) { |
| 84 |
return $value === 'yes' ? 1 : 0; |
| 85 |
}, Arr::only($pref, $globalKeys)); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Every flat pref key mapped to the cell it is stored in. |
| 90 |
* |
| 91 |
* @return array flat key => [channel, event_key, is_object_scoped] |
| 92 |
*/ |
| 93 |
public static function prefKeyMap() |
| 94 |
{ |
| 95 |
static $map; |
| 96 |
|
| 97 |
if ($map !== null) { |
| 98 |
return $map; |
| 99 |
} |
| 100 |
|
| 101 |
$map = []; |
| 102 |
|
| 103 |
foreach (self::NOTIFICATION_EVENTS as $event => $channels) { |
| 104 |
foreach ($channels as $channel => $flatKey) { |
| 105 |
$map[$flatKey] = [$channel, $event, false]; |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
return $map = array_merge($map, self::EXTRA_PREF_KEYS); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* The inverse: a stored cell mapped back to the flat key callers use. |
| 114 |
* |
| 115 |
* @return array "channel/event_key" => flat key |
| 116 |
*/ |
| 117 |
private static function cellKeyMap() |
| 118 |
{ |
| 119 |
static $map; |
| 120 |
|
| 121 |
if ($map !== null) { |
| 122 |
return $map; |
| 123 |
} |
| 124 |
|
| 125 |
$map = []; |
| 126 |
|
| 127 |
foreach (self::prefKeyMap() as $flatKey => $cell) { |
| 128 |
$map[$cell[0] . '/' . $cell[1]] = $flatKey; |
| 129 |
} |
| 130 |
|
| 131 |
return $map; |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* A member's explicit overrides, keyed the way every caller expects: |
| 136 |
* '<flat key>' globally, '<flat key>_<object id>' for space-scoped rows. |
| 137 |
* |
| 138 |
* @param int $userId |
| 139 |
* @return array |
| 140 |
*/ |
| 141 |
public static function getUserPrefs($userId) |
| 142 |
{ |
| 143 |
$cacheKey = self::CACHE_PREFIX . $userId; |
| 144 |
|
| 145 |
$cached = Utility::getFromCache($cacheKey); |
| 146 |
|
| 147 |
if ($cached !== false) { |
| 148 |
return $cached; |
| 149 |
} |
| 150 |
|
| 151 |
$prefs = Arr::get(self::loadUserPrefs([$userId]), $userId, []); |
| 152 |
|
| 153 |
// setCache rather than getFromCache's callback: a member with no overrides |
| 154 |
// at all is the common case, and getFromCache only stores truthy values, so |
| 155 |
// those users would miss the cache on every recipient of every fan-out. |
| 156 |
Utility::setCache($cacheKey, $prefs, 86400 * 30); |
| 157 |
|
| 158 |
return $prefs; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Is fcom_notification_prefs the whole truth yet? |
| 163 |
* |
| 164 |
* False while the backfill still has rows to copy. On a large site that is a |
| 165 |
* normal state, not an error one: maybeBackfillFromLegacy() gives up after 15 |
| 166 |
* seconds and resumes through Action Scheduler, so the table can sit partly |
| 167 |
* filled for minutes while the migration is working correctly. |
| 168 |
* |
| 169 |
* @return bool |
| 170 |
*/ |
| 171 |
private static function backfillIsComplete() |
| 172 |
{ |
| 173 |
return (bool)get_option(NotificationPrefMigrator::DONE_OPTION); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Load the flat pref arrays for many members in one query. |
| 178 |
* |
| 179 |
* @param array $userIds |
| 180 |
* @return array user id => [flat key => value] |
| 181 |
*/ |
| 182 |
private static function loadUserPrefs($userIds) |
| 183 |
{ |
| 184 |
$grouped = array_fill_keys($userIds, []); |
| 185 |
|
| 186 |
if (!$userIds) { |
| 187 |
return $grouped; |
| 188 |
} |
| 189 |
|
| 190 |
$rows = NotificationPreference::whereIn('user_id', $userIds) |
| 191 |
->select(['user_id', 'channel', 'event_key', 'object_id', 'value']) |
| 192 |
->get(); |
| 193 |
|
| 194 |
$cellKeys = self::cellKeyMap(); |
| 195 |
|
| 196 |
foreach ($rows as $row) { |
| 197 |
$flatKey = Arr::get($cellKeys, $row->channel . '/' . $row->event_key); |
| 198 |
|
| 199 |
if (!$flatKey) { |
| 200 |
continue; |
| 201 |
} |
| 202 |
|
| 203 |
if ($row->object_id) { |
| 204 |
$flatKey .= '_' . $row->object_id; |
| 205 |
} |
| 206 |
|
| 207 |
$grouped[$row->user_id][$flatKey] = (int)$row->value; |
| 208 |
} |
| 209 |
|
| 210 |
return $grouped; |
| 211 |
} |
| 212 |
|
| 213 |
public static function filterValidPrefs($prefs) |
| 214 |
{ |
| 215 |
$validPrefs = []; |
| 216 |
|
| 217 |
foreach ((array)$prefs as $key => $value) { |
| 218 |
if (in_array($key, self::validPrefKeys())) { |
| 219 |
$validPrefs[$key] = $value ? 1 : 0; |
| 220 |
} else if (strpos($key, 'np_by_') === 0) { |
| 221 |
// This is the notification by object. We are processing per key when updating |
| 222 |
$validPrefs[$key] = $value ? 1 : 0; |
| 223 |
} else if ($key == 'message_email_frequency') { |
| 224 |
$validPrefs[$key] = $value; |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
return $validPrefs; |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* Resolve a flat pref key to the cell it belongs in. |
| 233 |
* |
| 234 |
* @param string $key |
| 235 |
* @return array|null [channel, event_key, object_id] |
| 236 |
*/ |
| 237 |
private static function resolveCell($key) |
| 238 |
{ |
| 239 |
$map = self::prefKeyMap(); |
| 240 |
|
| 241 |
if (isset($map[$key])) { |
| 242 |
list($channel, $eventKey, $isScoped) = $map[$key]; |
| 243 |
|
| 244 |
// A scoped subscription without an object id addresses nothing. |
| 245 |
return $isScoped ? null : [$channel, $eventKey, 0]; |
| 246 |
} |
| 247 |
|
| 248 |
if (!preg_match('/^(.+)_(\d+)$/', $key, $matches)) { |
| 249 |
return null; |
| 250 |
} |
| 251 |
|
| 252 |
$baseKey = $matches[1]; |
| 253 |
$objectId = (int)$matches[2]; |
| 254 |
|
| 255 |
if (!isset($map[$baseKey])) { |
| 256 |
return null; |
| 257 |
} |
| 258 |
|
| 259 |
list($channel, $eventKey, $isScoped) = $map[$baseKey]; |
| 260 |
|
| 261 |
if (!$isScoped || !$objectId || !Space::where('id', $objectId)->exists()) { |
| 262 |
return null; |
| 263 |
} |
| 264 |
|
| 265 |
return [$channel, $eventKey, $objectId]; |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Replace a member's overrides. |
| 270 |
* |
| 271 |
* Only the channels present in $prefs are reconciled. Saving the email form |
| 272 |
* therefore cannot delete a member's push preferences - on the old shared |
| 273 |
* table this delete removed every row the payload did not mention, and its |
| 274 |
* safety against also deleting notification receipts rested entirely on a |
| 275 |
* global scope declared in a boot() closure. |
| 276 |
* |
| 277 |
* @param int $userId |
| 278 |
* @param array $prefs |
| 279 |
* @return array |
| 280 |
*/ |
| 281 |
public static function updateUserPrefs($userId, $prefs = []) |
| 282 |
{ |
| 283 |
$userId = (int)$userId; |
| 284 |
|
| 285 |
if (!$userId) { |
| 286 |
return []; |
| 287 |
} |
| 288 |
|
| 289 |
$cells = []; |
| 290 |
$channels = []; |
| 291 |
|
| 292 |
foreach (self::filterValidPrefs($prefs) as $key => $value) { |
| 293 |
$cell = self::resolveCell($key); |
| 294 |
|
| 295 |
if (!$cell) { |
| 296 |
continue; |
| 297 |
} |
| 298 |
|
| 299 |
list($channel, $eventKey, $objectId) = $cell; |
| 300 |
|
| 301 |
$cells[$channel . '/' . $eventKey . '/' . $objectId] = [ |
| 302 |
'channel' => $channel, |
| 303 |
'event_key' => $eventKey, |
| 304 |
'object_id' => $objectId, |
| 305 |
'value' => (int)$value, |
| 306 |
]; |
| 307 |
|
| 308 |
$channels[$channel] = true; |
| 309 |
} |
| 310 |
|
| 311 |
$channels = array_keys($channels); |
| 312 |
|
| 313 |
if (!$channels) { |
| 314 |
return self::getUserPrefs($userId); |
| 315 |
} |
| 316 |
|
| 317 |
// One read of the member's current rows, then a diff. The previous |
| 318 |
// implementation ran a SELECT per pref key. |
| 319 |
$existing = []; |
| 320 |
$existingRows = NotificationPreference::where('user_id', $userId) |
| 321 |
->whereIn('channel', $channels) |
| 322 |
->get(); |
| 323 |
|
| 324 |
foreach ($existingRows as $row) { |
| 325 |
$existing[$row->channel . '/' . $row->event_key . '/' . $row->object_id] = $row; |
| 326 |
} |
| 327 |
|
| 328 |
$keptIds = []; |
| 329 |
|
| 330 |
foreach ($cells as $cellKey => $cell) { |
| 331 |
if (isset($existing[$cellKey])) { |
| 332 |
/** @var NotificationPreference $row */ |
| 333 |
$row = $existing[$cellKey]; |
| 334 |
|
| 335 |
if ((int)$row->value !== $cell['value']) { |
| 336 |
$row->value = $cell['value']; |
| 337 |
$row->save(); |
| 338 |
} |
| 339 |
|
| 340 |
$keptIds[] = $row->id; |
| 341 |
continue; |
| 342 |
} |
| 343 |
|
| 344 |
$created = NotificationPreference::create(array_merge($cell, ['user_id' => $userId])); |
| 345 |
|
| 346 |
$keptIds[] = $created->id; |
| 347 |
} |
| 348 |
|
| 349 |
$staleQuery = NotificationPreference::where('user_id', $userId) |
| 350 |
->whereIn('channel', $channels); |
| 351 |
|
| 352 |
if ($keptIds) { |
| 353 |
$staleQuery->whereNotIn('id', $keptIds); |
| 354 |
} |
| 355 |
|
| 356 |
$staleQuery->delete(); |
| 357 |
|
| 358 |
self::forgetUserCache($userId); |
| 359 |
self::refreshAggregates(); |
| 360 |
|
| 361 |
return self::getUserPrefs($userId); |
| 362 |
} |
| 363 |
|
| 364 |
public static function updateUserSinglePref($userId, $prefKey, $prefValue, $objectId = null) |
| 365 |
{ |
| 366 |
$prefs = self::getUserPrefs($userId); |
| 367 |
$prefs[$prefKey] = $prefValue ? 1 : 0; |
| 368 |
|
| 369 |
if ($objectId) { |
| 370 |
$prefs[$prefKey . '_' . $objectId] = $prefValue; |
| 371 |
} |
| 372 |
|
| 373 |
return self::updateUserPrefs($userId, $prefs); |
| 374 |
} |
| 375 |
|
| 376 |
public static function isPrefEnabled($userId, $prefKey, $globalStatus = false) |
| 377 |
{ |
| 378 |
$prefs = self::getUserPrefs($userId); |
| 379 |
|
| 380 |
if (!isset($prefs[$prefKey])) { |
| 381 |
return $globalStatus; |
| 382 |
} |
| 383 |
|
| 384 |
return (bool)$prefs[$prefKey]; |
| 385 |
} |
| 386 |
|
| 387 |
public static function validPrefKeys() |
| 388 |
{ |
| 389 |
static $keys; |
| 390 |
|
| 391 |
if ($keys !== null) { |
| 392 |
return $keys; |
| 393 |
} |
| 394 |
|
| 395 |
$keys = []; |
| 396 |
|
| 397 |
foreach (self::NOTIFICATION_EVENTS as $channels) { |
| 398 |
foreach ($channels as $key) { |
| 399 |
$keys[] = $key; |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
return $keys; |
| 404 |
} |
| 405 |
|
| 406 |
public static function getGlobalKeyFor($event, $type) |
| 407 |
{ |
| 408 |
$path = $event . '.' . $type; |
| 409 |
|
| 410 |
return Arr::get(self::GLOBAL_KEY_OVERRIDES, $path) ?: Arr::get(self::NOTIFICATION_EVENTS, $path); |
| 411 |
} |
| 412 |
|
| 413 |
public static function willGetNotification($userId, $event, $type = 'mail', $globalStatus = null) |
| 414 |
{ |
| 415 |
$prefKey = Arr::get(self::NOTIFICATION_EVENTS, $event . '.' . $type); |
| 416 |
|
| 417 |
// This event has no such channel, e.g. digest.push (event = digest, type = push) |
| 418 |
if (!$prefKey) return false; |
| 419 |
|
| 420 |
if ($globalStatus === null) { |
| 421 |
$globalKey = self::getGlobalKeyFor($event, $type); |
| 422 |
$globalStatus = (bool)Arr::get(self::getGlobalPrefs($type), $globalKey, false); |
| 423 |
} |
| 424 |
|
| 425 |
return self::isPrefEnabled($userId, $prefKey, $globalStatus); |
| 426 |
} |
| 427 |
|
| 428 |
public static function primeUserPrefs($userIds) |
| 429 |
{ |
| 430 |
$missing = []; |
| 431 |
|
| 432 |
foreach ($userIds as $userId) { |
| 433 |
if (Utility::getFromCache(self::CACHE_PREFIX . $userId) === false) { |
| 434 |
$missing[] = $userId; |
| 435 |
} |
| 436 |
} |
| 437 |
|
| 438 |
if (!$missing) return; |
| 439 |
|
| 440 |
foreach (self::loadUserPrefs($missing) as $userId => $userPrefs) { |
| 441 |
Utility::setCache(self::CACHE_PREFIX . $userId, $userPrefs, 86400 * 30); |
| 442 |
} |
| 443 |
} |
| 444 |
|
| 445 |
public static function filterPushUserIds($userIds, $event) |
| 446 |
{ |
| 447 |
if (!$userIds || !$event) return []; |
| 448 |
|
| 449 |
$prefKey = Arr::get(self::NOTIFICATION_EVENTS, $event . '.push'); |
| 450 |
|
| 451 |
if (!$prefKey) return []; |
| 452 |
|
| 453 |
$userIds = array_filter(array_map('intval', (array)$userIds), function ($userId) { |
| 454 |
return $userId > 0; |
| 455 |
}); |
| 456 |
|
| 457 |
$userIds = array_values(array_unique($userIds)); |
| 458 |
|
| 459 |
if (!$userIds) return []; |
| 460 |
|
| 461 |
$globalKey = self::getGlobalKeyFor($event, 'push'); |
| 462 |
$globalStatus = (bool)Arr::get(self::getGlobalPrefs('push'), $globalKey, false); |
| 463 |
|
| 464 |
self::primeUserPrefs($userIds); |
| 465 |
|
| 466 |
$enabled = []; |
| 467 |
|
| 468 |
foreach ($userIds as $userId) { |
| 469 |
if (self::isPrefEnabled($userId, $prefKey, $globalStatus)) { |
| 470 |
$enabled[] = $userId; |
| 471 |
} |
| 472 |
} |
| 473 |
|
| 474 |
return $enabled; |
| 475 |
} |
| 476 |
|
| 477 |
public static function forgetUserCache($userId) |
| 478 |
{ |
| 479 |
Utility::forgetCache(self::CACHE_PREFIX . $userId); |
| 480 |
} |
| 481 |
|
| 482 |
/** |
| 483 |
* (channel, event) pairs that need a "does anybody have this on?" answer |
| 484 |
* across all members. Answered from a denormalized option refreshed on the |
| 485 |
* preference write path, so the hourly digest check never scans. |
| 486 |
* |
| 487 |
* @return array list of [channel, event_key] |
| 488 |
*/ |
| 489 |
public static function getAggregatedPrefs() |
| 490 |
{ |
| 491 |
return apply_filters('fluent_community/aggregated_notification_prefs', [ |
| 492 |
['mail', 'digest'], |
| 493 |
]); |
| 494 |
} |
| 495 |
|
| 496 |
/** |
| 497 |
* @param string $eventKey |
| 498 |
* @param string $channel |
| 499 |
* @return bool |
| 500 |
*/ |
| 501 |
public static function hasAnyEnabled($eventKey, $channel = 'mail') |
| 502 |
{ |
| 503 |
$aggregates = get_option(self::AGGREGATE_OPTION); |
| 504 |
|
| 505 |
if (!is_array($aggregates)) { |
| 506 |
$aggregates = self::refreshAggregates(); |
| 507 |
} |
| 508 |
|
| 509 |
return !empty($aggregates[$channel . '.' . $eventKey]); |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* @return array |
| 514 |
*/ |
| 515 |
public static function refreshAggregates() |
| 516 |
{ |
| 517 |
$aggregates = []; |
| 518 |
|
| 519 |
foreach (self::getAggregatedPrefs() as $pair) { |
| 520 |
list($channel, $eventKey) = $pair; |
| 521 |
|
| 522 |
$aggregates[$channel . '.' . $eventKey] = NotificationPreference::query() |
| 523 |
->where('channel', $channel) |
| 524 |
->where('event_key', $eventKey) |
| 525 |
->where('value', 1) |
| 526 |
->exists(); |
| 527 |
} |
| 528 |
|
| 529 |
/* |
| 530 |
* Persisted only once the table is whole. |
| 531 |
* |
| 532 |
* This option is a cache with no expiry and one writer - the preference |
| 533 |
* write path - so whatever lands here is not revisited until some member |
| 534 |
* happens to save their preferences. Computed mid-backfill it says "nobody |
| 535 |
* has the digest on", and Scheduler::checkDailyDigestSchedule() unschedules |
| 536 |
* the digest on that answer - an unschedule that would then outlive the |
| 537 |
* migration that made it wrong. Skipping the write costs one indexed |
| 538 |
* EXISTS per call for the duration of the backfill; markComplete() clears |
| 539 |
* the option, so the first read afterwards recomputes and stores. |
| 540 |
*/ |
| 541 |
if (self::backfillIsComplete()) { |
| 542 |
update_option(self::AGGREGATE_OPTION, $aggregates, false); |
| 543 |
} |
| 544 |
|
| 545 |
return $aggregates; |
| 546 |
} |
| 547 |
} |
| 548 |
|