1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Jobs\Notifications;
7
8use App\Events\NewPrivateNotificationEvent;
9use App\Exceptions\InvalidNotificationException;
10use App\Models\Notification;
11use App\Models\User;
12use App\Models\UserGroup;
13use App\Models\UserNotification;
14use App\Models\UserNotificationOption;
15use App\Traits\NotificationQueue;
16use Illuminate\Bus\Queueable;
17use Illuminate\Contracts\Queue\ShouldQueue;
18use Illuminate\Queue\SerializesModels;
19
20abstract class BroadcastNotificationBase implements ShouldQueue
21{
22 use NotificationQueue, Queueable, SerializesModels;
23
24 const CONTENT_TRUNCATE = 36;
25 const DELIVERY_MODE_DEFAULTS = ['mail' => false, 'push' => true];
26 const NOTIFICATION_OPTION_NAME = null;
27
28 protected $name;
29 protected $source;
30 protected $timestamp;
31
32 public static function getBaseKey(Notification $notification): string
33 {
34 return "{$notification->notifiable_type}.{$notification->category}.{$notification->name}";
35 }
36
37 public static function getMailGroupingKey(Notification $notification): string
38 {
39 $base = static::getBaseKey($notification);
40
41 return "{$base}-{$notification->notifiable_type}-{$notification->notifiable_id}";
42 }
43
44 abstract public static function getMailLink(Notification $notification): string;
45
46 public static function getNotificationClass(string $name)
47 {
48 $class = get_class_namespace(static::class).'\\'.studly_case($name);
49
50 if (!class_exists($class)) {
51 throw new InvalidNotificationException('Invalid event name: '.$name);
52 }
53
54 return $class;
55 }
56
57 public static function getNotificationClassFromNotification(Notification $notification)
58 {
59 return static::getNotificationClass($notification->name);
60 }
61
62 /**
63 * Checks if a notification should be included into the user notification mail digest.
64 * This mainly used to exclude the more generic notifications from being re-notified if they haven't been 'read'.
65 * The notifications with more specific messages or links should still be included, since they're usually going to be
66 * different in each digest.
67 *
68 * @param Notification $notification The notification to check.
69 * @param $watches A keyed collection of watches; this is here because there's no DB context cache but watches need preloading,
70 * so it's preloaded and then passed in.
71 * @param $time The time the mail notification is considered run at.
72 * @return bool
73 */
74 public static function shouldSendMail(Notification $notification, $watches, $time): bool
75 {
76 return true;
77 }
78
79 private static function applyDeliverySettings(array $userIds)
80 {
81 if (static::NOTIFICATION_OPTION_NAME !== null) {
82 $notificationOptionsQuery = UserNotificationOption
83 ::where(['name' => static::NOTIFICATION_OPTION_NAME])
84 ->whereNotNull('details');
85 }
86
87 $deliverySettings = [];
88
89 foreach (array_chunk($userIds, 10000) as $chunkedUserIds) {
90 if (isset($notificationOptionsQuery)) {
91 $notificationOptions = (clone $notificationOptionsQuery)
92 ->whereIntegerInRaw('user_id', $chunkedUserIds)
93 ->get()
94 ->keyBy('user_id');
95 } else {
96 $notificationOptions = [];
97 }
98
99 foreach ($chunkedUserIds as $userId) {
100 $details = $notificationOptions[$userId]->details ?? static::DELIVERY_MODE_DEFAULTS;
101 $delivery = 0;
102 foreach (UserNotification::DELIVERY_OFFSETS as $type => $_offset) {
103 if ($details[$type] ?? static::DELIVERY_MODE_DEFAULTS[$type]) {
104 $delivery |= UserNotification::deliveryMask($type);
105 }
106 }
107
108 if ($delivery !== 0) {
109 $deliverySettings[$userId] = $delivery;
110 }
111 }
112 }
113
114 return $deliverySettings;
115 }
116
117 private static function excludeBotUserIds(array $userIds)
118 {
119 $botGroupId = app('groups')->byIdentifier('bot')->getKey();
120
121 $allBotUserIds = UserGroup
122 ::where('group_id', $botGroupId)
123 ->select('user_id');
124
125 // only consider users with bot as their primary group
126 $botUserIds = User
127 ::where('group_id', $botGroupId)
128 ->whereIn('user_id', $allBotUserIds)
129 ->pluck('user_id')
130 ->all();
131
132 return array_values(array_diff($userIds, $botUserIds));
133 }
134
135 public function __construct(?User $source = null)
136 {
137 $this->name = snake_case(get_class_basename(get_class($this)));
138 $this->source = $source;
139 $this->afterCommit = true;
140 }
141
142 abstract public function getDetails(): array;
143
144 abstract public function getListeningUserIds(): array;
145
146 public function getName()
147 {
148 return $this->name;
149 }
150
151 abstract public function getNotifiable();
152
153 /**
154 * In most cases this is a deduplicated list that excludes the user id that
155 * triggered the notifications. This should be overriden in cases where the source user id shouldn't be removed.
156 * e.g. UserAchievementUnlock.
157 */
158 public function getReceiverIds(): array
159 {
160 return array_values(array_unique(array_diff($this->getListeningUserIds(), [optional($this->source)->getKey()])));
161 }
162
163 public function getTimestamp()
164 {
165 if ($this->timestamp === null) {
166 $this->timestamp = now();
167 }
168
169 return $this->timestamp;
170 }
171
172 public function handle()
173 {
174 $deliverySettings = static::applyDeliverySettings(static::excludeBotUserIds($this->getReceiverIds()));
175
176 if (empty($deliverySettings)) {
177 return;
178 }
179
180 $notification = $this->makeNotification();
181 $notification->saveOrExplode();
182
183 // client should now be able to handle push notifications that come in after notification has been loaded,
184 // so, it should be fine to create the user notifications first.
185
186 $pushReceiverIds = [];
187 $notification->getConnection()->transaction(function () use ($deliverySettings, $notification, &$pushReceiverIds) {
188 $timestamp = $this->getTimestamp()->format('Y-m-d H:i:s');
189 $notificationId = $notification->getKey();
190 $tempUserNotification = new UserNotification();
191
192 $userNotifications = [];
193 foreach ($deliverySettings as $userId => $delivery) {
194 $userNotifications[] = [
195 'created_at' => $timestamp,
196 'delivery' => $delivery,
197 'notification_id' => $notificationId,
198 'updated_at' => $timestamp,
199 'user_id' => $userId,
200 ];
201 $tempUserNotification->delivery = $delivery;
202 $tempUserNotification->isPush() && $pushReceiverIds[] = $userId;
203
204 if (count($userNotifications) === 1000) {
205 UserNotification::insert($userNotifications);
206 $userNotifications = [];
207 }
208 }
209 UserNotification::insert($userNotifications);
210 });
211
212 if (!empty($pushReceiverIds)) {
213 (new NewPrivateNotificationEvent($notification, $pushReceiverIds))->broadcast();
214 }
215 }
216
217 public function makeNotification(): Notification
218 {
219 $params['created_at'] = $this->getTimestamp();
220 $params['details'] = $this->getDetails();
221 $params['name'] = $this->name;
222
223 if ($this->source !== null) {
224 $params['details']['username'] = $this->source->username;
225 }
226
227 $notification = new Notification($params);
228 $notification->notifiable()->associate($this->getNotifiable());
229 if ($this->source !== null) {
230 $notification->source()->associate($this->source);
231 }
232
233 return $notification;
234 }
235}