PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.9.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.9.0
2.11.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 All 78 releases
fluent-community / app / Services / NotificationPref.php

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

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