| @@ -2,117 +2,363 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace FluentCommunity\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentCommunity\App\Functions\Utility; |
| 6 | -use FluentCommunity\App\Models\NotificationSubscription; | |
| 6 | +use FluentCommunity\App\Models\NotificationPreference; | |
| 7 | 7 | use FluentCommunity\App\Models\Space; |
| 8 | +use FluentCommunity\Database\Migrations\NotificationPrefMigrator; | |
| 8 | 9 | use FluentCommunity\Framework\Support\Arr; |
| 9 | 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 | + */ | |
| 10 | 30 | class NotificationPref |
| 11 | 31 | { |
| 12 | - public static function getGlobalPrefs() | |
| 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') | |
| 13 | 65 | { |
| 14 | - $pref = Utility::getEmailNotificationSettings(); | |
| 15 | - $valids = ['com_my_post_mail', 'reply_my_com_mail', 'mention_mail', 'digest_email_status']; | |
| 66 | + if ($type === 'push') { | |
| 67 | + $pref = Utility::getPushNotificationSettings(); | |
| 68 | + } else { | |
| 69 | + $type = 'mail'; | |
| 70 | + $pref = Utility::getEmailNotificationSettings(); | |
| 71 | + } | |
| 16 | 72 | |
| 17 | - $pref = Arr::only($pref, $valids); | |
| 73 | + $globalKeys = []; | |
| 18 | 74 | |
| 19 | - $pref = array_map(function ($value) { | |
| 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) { | |
| 20 | 84 | return $value === 'yes' ? 1 : 0; |
| 21 | - }, $pref); | |
| 85 | + }, Arr::only($pref, $globalKeys)); | |
| 86 | + } | |
| 22 | 87 | |
| 23 | - return $pref; | |
| 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); | |
| 24 | 110 | } |
| 25 | 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 | + */ | |
| 26 | 141 | public static function getUserPrefs($userId) |
| 27 | 142 | { |
| 28 | - return Utility::getFromCache('user_notification_pref_' . $userId, function () use ($userId) { | |
| 29 | - $prefs = NotificationSubscription::where('user_id', $userId) | |
| 30 | - ->select(['notification_type', 'is_read', 'object_id']) | |
| 31 | - ->get(); | |
| 143 | + $cacheKey = self::CACHE_PREFIX . $userId; | |
| 32 | 144 | |
| 33 | - if ($prefs->isEmpty()) { | |
| 34 | - return []; | |
| 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; | |
| 35 | 201 | } |
| 36 | 202 | |
| 37 | - $fromattedPrefs = []; | |
| 38 | - foreach ($prefs as $pref) { | |
| 39 | - $key = $pref->notification_type; | |
| 40 | - if ($pref->object_id) { | |
| 41 | - $key = $pref->notification_type . '_' . $pref->object_id; | |
| 42 | - } | |
| 43 | - $fromattedPrefs[$key] = $pref->is_read; | |
| 203 | + if ($row->object_id) { | |
| 204 | + $flatKey .= '_' . $row->object_id; | |
| 44 | 205 | } |
| 45 | - return $fromattedPrefs; | |
| 46 | - }, 86400 * 30); | |
| 206 | + | |
| 207 | + $grouped[$row->user_id][$flatKey] = (int)$row->value; | |
| 208 | + } | |
| 209 | + | |
| 210 | + return $grouped; | |
| 47 | 211 | } |
| 48 | 212 | |
| 49 | - public static function updateUserPrefs($userId, $prefs = []) | |
| 213 | + public static function filterValidPrefs($prefs) | |
| 50 | 214 | { |
| 51 | - $validKeys = [ | |
| 52 | - 'com_my_post_mail', | |
| 53 | - 'reply_my_com_mail', | |
| 54 | - 'mention_mail', | |
| 55 | - 'digest_mail' | |
| 56 | - ]; | |
| 215 | + $validPrefs = []; | |
| 57 | 216 | |
| 58 | - $validPrefs = []; | |
| 59 | - foreach ($prefs as $key => $value) { | |
| 60 | - if (in_array($key, $validKeys)) { | |
| 217 | + foreach ((array)$prefs as $key => $value) { | |
| 218 | + if (in_array($key, self::validPrefKeys())) { | |
| 61 | 219 | $validPrefs[$key] = $value ? 1 : 0; |
| 62 | 220 | } else if (strpos($key, 'np_by_') === 0) { |
| 63 | - // now remove the _{number} from the key | |
| 221 | + // This is the notification by object. We are processing per key when updating | |
| 64 | 222 | $validPrefs[$key] = $value ? 1 : 0; |
| 223 | + } else if ($key == 'message_email_frequency') { | |
| 224 | + $validPrefs[$key] = $value; | |
| 65 | 225 | } |
| 66 | 226 | } |
| 67 | 227 | |
| 68 | - $ids = []; | |
| 69 | - foreach ($validPrefs as $key => $value) { | |
| 70 | - $exist = NotificationSubscription::where('user_id', $userId) | |
| 71 | - ->where('notification_type', $key) | |
| 72 | - ->first(); | |
| 73 | - if ($exist) { | |
| 74 | - $exist->is_read = $value; | |
| 75 | - $exist->save(); | |
| 76 | - $ids[] = $exist->id; | |
| 228 | + return $validPrefs; | |
| 229 | + } | |
| 77 | 230 | |
| 78 | - } else { | |
| 79 | - $newData = [ | |
| 80 | - 'user_id' => $userId, | |
| 81 | - 'object_type' => 'notification_pref', | |
| 82 | - 'notification_type' => $key, | |
| 83 | - 'is_read' => $value | |
| 84 | - ]; | |
| 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(); | |
| 85 | 240 | |
| 86 | - $isByObject = strpos($key, 'np_by_') === 0; | |
| 241 | + if (isset($map[$key])) { | |
| 242 | + list($channel, $eventKey, $isScoped) = $map[$key]; | |
| 87 | 243 | |
| 88 | - // Find the last number in the key _{number} | |
| 89 | - if ($isByObject) { | |
| 90 | - $matches = []; | |
| 91 | - preg_match('/\d+$/', $key, $matches); | |
| 92 | - if (isset($matches[0])) { | |
| 93 | - $objectId = (int)$matches[0]; | |
| 94 | - if ($objectId && Space::where('id', $objectId)->exists()) { | |
| 95 | - // Now remove the last _{number} from the key. Make sure you are removing the last one | |
| 96 | - $newData['notification_type'] = substr($key, 0, strrpos($key, '_')); | |
| 97 | - $newData['object_id'] = $objectId; | |
| 98 | - } | |
| 99 | - } | |
| 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(); | |
| 100 | 338 | } |
| 101 | 339 | |
| 102 | - $created = NotificationSubscription::create($newData); | |
| 103 | - $ids[] = $created->id; | |
| 340 | + $keptIds[] = $row->id; | |
| 341 | + continue; | |
| 104 | 342 | } |
| 343 | + | |
| 344 | + $created = NotificationPreference::create(array_merge($cell, ['user_id' => $userId])); | |
| 345 | + | |
| 346 | + $keptIds[] = $created->id; | |
| 105 | 347 | } |
| 106 | 348 | |
| 107 | - // Delete the rest | |
| 108 | - NotificationSubscription::where('user_id', $userId) | |
| 109 | - ->whereNotIn('id', $ids) | |
| 110 | - ->delete(); | |
| 349 | + $staleQuery = NotificationPreference::where('user_id', $userId) | |
| 350 | + ->whereIn('channel', $channels); | |
| 111 | 351 | |
| 112 | - // delete the cache now | |
| 113 | - Utility::forgetCache('user_notification_pref_' . $userId); | |
| 352 | + if ($keptIds) { | |
| 353 | + $staleQuery->whereNotIn('id', $keptIds); | |
| 354 | + } | |
| 114 | 355 | |
| 356 | + $staleQuery->delete(); | |
| 357 | + | |
| 358 | + self::forgetUserCache($userId); | |
| 359 | + self::refreshAggregates(); | |
| 360 | + | |
| 115 | 361 | return self::getUserPrefs($userId); |
| 116 | 362 | } |
| 117 | 363 | |
| 118 | 364 | public static function updateUserSinglePref($userId, $prefKey, $prefValue, $objectId = null) |
| @@ -137,41 +383,165 @@ | ||
| 137 | 383 | |
| 138 | 384 | return (bool)$prefs[$prefKey]; |
| 139 | 385 | } |
| 140 | 386 | |
| 141 | - public static function willGetCommentEmail($userId, $globalStatus = null) | |
| 387 | + public static function validPrefKeys() | |
| 142 | 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 | + | |
| 143 | 420 | if ($globalStatus === null) { |
| 144 | - $globalStatus = Arr::get(self::getGlobalPrefs(), 'com_my_post_mail', false); | |
| 421 | + $globalKey = self::getGlobalKeyFor($event, $type); | |
| 422 | + $globalStatus = (bool)Arr::get(self::getGlobalPrefs($type), $globalKey, false); | |
| 145 | 423 | } |
| 146 | 424 | |
| 147 | - return self::isPrefEnabled($userId, 'com_my_post_mail', $globalStatus); | |
| 425 | + return self::isPrefEnabled($userId, $prefKey, $globalStatus); | |
| 148 | 426 | } |
| 149 | 427 | |
| 150 | - public static function willGetCommentReplyEmail($userId, $globalStatus = null) | |
| 428 | + public static function primeUserPrefs($userIds) | |
| 151 | 429 | { |
| 152 | - if ($globalStatus === null) { | |
| 153 | - $globalStatus = Arr::get(self::getGlobalPrefs(), 'reply_my_com_mail', false); | |
| 430 | + $missing = []; | |
| 431 | + | |
| 432 | + foreach ($userIds as $userId) { | |
| 433 | + if (Utility::getFromCache(self::CACHE_PREFIX . $userId) === false) { | |
| 434 | + $missing[] = $userId; | |
| 435 | + } | |
| 154 | 436 | } |
| 155 | 437 | |
| 156 | - return self::isPrefEnabled($userId, 'reply_my_com_mail', $globalStatus); | |
| 438 | + if (!$missing) return; | |
| 439 | + | |
| 440 | + foreach (self::loadUserPrefs($missing) as $userId => $userPrefs) { | |
| 441 | + Utility::setCache(self::CACHE_PREFIX . $userId, $userPrefs, 86400 * 30); | |
| 442 | + } | |
| 157 | 443 | } |
| 158 | 444 | |
| 159 | - public static function willGetMentionEmail($userId, $globalStatus = null) | |
| 445 | + public static function filterPushUserIds($userIds, $event) | |
| 160 | 446 | { |
| 161 | - if ($globalStatus === null) { | |
| 162 | - $globalStatus = Arr::get(self::getGlobalPrefs(), 'mention_mail', false); | |
| 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 | + } | |
| 163 | 472 | } |
| 164 | 473 | |
| 165 | - return self::isPrefEnabled($userId, 'mention_mail', $globalStatus); | |
| 474 | + return $enabled; | |
| 166 | 475 | } |
| 167 | 476 | |
| 168 | - public static function willGetDigestEmail($userId, $globalStatus = null) | |
| 477 | + public static function forgetUserCache($userId) | |
| 169 | 478 | { |
| 170 | - if ($globalStatus === null) { | |
| 171 | - $globalStatus = Arr::get(self::getGlobalPrefs(), 'digest_mail', false); | |
| 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(); | |
| 172 | 507 | } |
| 173 | 508 | |
| 174 | - return self::isPrefEnabled($userId, 'digest_mail', $globalStatus); | |
| 509 | + return !empty($aggregates[$channel . '.' . $eventKey]); | |
| 175 | 510 | } |
| 176 | 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 | + } | |
| 177 | 547 | } |