PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
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
← All changes | app/Services/NotificationPref.php +456 -86 1.0.952.10.0 View file →
@@ -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 }