PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.9.1
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.9.1
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
fluent-community / app / Services / NotificationPref.php

NotificationPref.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.9.1, at app/Services/NotificationPref.php

548 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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