Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / calendar / notifications / PhabricatorCalendarNotificationEngine.php
blobcf7fc30554bccaa0c3ef6e40ffbeb90b65556d07
1 <?php
3 final class PhabricatorCalendarNotificationEngine
4 extends Phobject {
6 private $cursor;
7 private $notifyWindow;
9 public function getCursor() {
10 if (!$this->cursor) {
11 $now = PhabricatorTime::getNow();
12 $this->cursor = $now - phutil_units('10 minutes in seconds');
15 return $this->cursor;
18 public function setCursor($cursor) {
19 $this->cursor = $cursor;
20 return $this;
23 public function setNotifyWindow($notify_window) {
24 $this->notifyWindow = $notify_window;
25 return $this;
28 public function getNotifyWindow() {
29 if (!$this->notifyWindow) {
30 return phutil_units('15 minutes in seconds');
33 return $this->notifyWindow;
36 public function publishNotifications() {
37 $cursor = $this->getCursor();
39 $now = PhabricatorTime::getNow();
40 if ($cursor > $now) {
41 return;
44 $calendar_class = 'PhabricatorCalendarApplication';
45 if (!PhabricatorApplication::isClassInstalled($calendar_class)) {
46 return;
49 try {
50 $lock = PhabricatorGlobalLock::newLock('calendar.notify')
51 ->lock(5);
52 } catch (PhutilLockException $ex) {
53 return;
56 $caught = null;
57 try {
58 $this->sendNotifications();
59 } catch (Exception $ex) {
60 $caught = $ex;
63 $lock->unlock();
65 // Wait a little while before checking for new notifications to send.
66 $this->setCursor($cursor + phutil_units('1 minute in seconds'));
68 if ($caught) {
69 throw $caught;
73 private function sendNotifications() {
74 $cursor = $this->getCursor();
76 $window_min = $cursor - phutil_units('16 hours in seconds');
77 $window_max = $cursor + phutil_units('16 hours in seconds');
79 $viewer = PhabricatorUser::getOmnipotentUser();
81 $events = id(new PhabricatorCalendarEventQuery())
82 ->setViewer($viewer)
83 ->withDateRange($window_min, $window_max)
84 ->withIsCancelled(false)
85 ->withIsImported(false)
86 ->setGenerateGhosts(true)
87 ->execute();
88 if (!$events) {
89 // No events are starting soon in any timezone, so there is nothing
90 // left to be done.
91 return;
94 $attendee_map = array();
95 foreach ($events as $key => $event) {
96 $notifiable_phids = array();
97 foreach ($event->getInvitees() as $invitee) {
98 if (!$invitee->isAttending()) {
99 continue;
101 $notifiable_phids[] = $invitee->getInviteePHID();
103 if ($notifiable_phids) {
104 $attendee_map[$key] = array_fuse($notifiable_phids);
105 } else {
106 unset($events[$key]);
109 if (!$attendee_map) {
110 // None of the events have any notifiable attendees, so there is no
111 // one to notify of anything.
112 return;
115 $all_attendees = array();
116 foreach ($attendee_map as $key => $attendee_phids) {
117 foreach ($attendee_phids as $attendee_phid) {
118 $all_attendees[$attendee_phid] = $attendee_phid;
122 $user_map = id(new PhabricatorPeopleQuery())
123 ->setViewer($viewer)
124 ->withPHIDs($all_attendees)
125 ->withIsDisabled(false)
126 ->needUserSettings(true)
127 ->execute();
128 $user_map = mpull($user_map, null, 'getPHID');
129 if (!$user_map) {
130 // None of the attendees are valid users: they're all imported users
131 // or projects or invalid or some other kind of unnotifiable entity.
132 return;
135 $all_event_phids = array();
136 foreach ($events as $key => $event) {
137 foreach ($event->getNotificationPHIDs() as $phid) {
138 $all_event_phids[$phid] = $phid;
142 $table = new PhabricatorCalendarNotification();
143 $conn = $table->establishConnection('w');
145 $rows = queryfx_all(
146 $conn,
147 'SELECT * FROM %T WHERE eventPHID IN (%Ls) AND targetPHID IN (%Ls)',
148 $table->getTableName(),
149 $all_event_phids,
150 $all_attendees);
151 $sent_map = array();
152 foreach ($rows as $row) {
153 $event_phid = $row['eventPHID'];
154 $target_phid = $row['targetPHID'];
155 $initial_epoch = $row['utcInitialEpoch'];
156 $sent_map[$event_phid][$target_phid][$initial_epoch] = $row;
159 $now = PhabricatorTime::getNow();
160 $notify_min = $now;
161 $notify_max = $now + $this->getNotifyWindow();
162 $notify_map = array();
163 foreach ($events as $key => $event) {
164 $initial_epoch = $event->getUTCInitialEpoch();
165 $event_phids = $event->getNotificationPHIDs();
167 // Select attendees who actually exist, and who we have not sent any
168 // notifications to yet.
169 $attendee_phids = $attendee_map[$key];
170 $users = array_select_keys($user_map, $attendee_phids);
171 foreach ($users as $user_phid => $user) {
172 foreach ($event_phids as $event_phid) {
173 if (isset($sent_map[$event_phid][$user_phid][$initial_epoch])) {
174 unset($users[$user_phid]);
175 continue 2;
180 if (!$users) {
181 continue;
184 // Discard attendees for whom the event start time isn't soon. Events
185 // may start at different times for different users, so we need to
186 // check every user's start time.
187 foreach ($users as $user_phid => $user) {
188 $user_datetime = $event->newStartDateTime()
189 ->setViewerTimezone($user->getTimezoneIdentifier());
191 $user_epoch = $user_datetime->getEpoch();
192 if ($user_epoch < $notify_min || $user_epoch > $notify_max) {
193 unset($users[$user_phid]);
194 continue;
197 $view = id(new PhabricatorCalendarEventNotificationView())
198 ->setViewer($user)
199 ->setEvent($event)
200 ->setDateTime($user_datetime)
201 ->setEpoch($user_epoch);
203 $notify_map[$user_phid][] = $view;
207 $mail_list = array();
208 $mark_list = array();
209 $now = PhabricatorTime::getNow();
210 foreach ($notify_map as $user_phid => $events) {
211 $user = $user_map[$user_phid];
213 $locale = PhabricatorEnv::beginScopedLocale($user->getTranslation());
214 $caught = null;
215 try {
216 $mail_list[] = $this->newMailMessage($user, $events);
217 } catch (Exception $ex) {
218 $caught = $ex;
221 unset($locale);
223 if ($caught) {
224 throw $ex;
227 foreach ($events as $view) {
228 $event = $view->getEvent();
229 foreach ($event->getNotificationPHIDs() as $phid) {
230 $mark_list[] = qsprintf(
231 $conn,
232 '(%s, %s, %d, %d)',
233 $phid,
234 $user_phid,
235 $event->getUTCInitialEpoch(),
236 $now);
241 // Mark all the notifications we're about to send as delivered so we
242 // do not double-notify.
243 foreach (PhabricatorLiskDAO::chunkSQL($mark_list) as $chunk) {
244 queryfx(
245 $conn,
246 'INSERT IGNORE INTO %T
247 (eventPHID, targetPHID, utcInitialEpoch, didNotifyEpoch)
248 VALUES %LQ',
249 $table->getTableName(),
250 $chunk);
253 foreach ($mail_list as $mail) {
254 $mail->saveAndSend();
259 private function newMailMessage(PhabricatorUser $viewer, array $events) {
260 $events = msort($events, 'getEpoch');
262 $next_event = head($events);
264 $body = new PhabricatorMetaMTAMailBody();
265 foreach ($events as $event) {
266 $body->addTextSection(
267 null,
268 pht(
269 '%s is starting in %s minute(s), at %s.',
270 $event->getEvent()->getName(),
271 $event->getDisplayMinutes(),
272 $event->getDisplayTimeWithTimezone()));
274 $body->addLinkSection(
275 pht('EVENT DETAIL'),
276 PhabricatorEnv::getProductionURI($event->getEvent()->getURI()));
279 $next_event = head($events)->getEvent();
280 $subject = $next_event->getName();
281 if (count($events) > 1) {
282 $more = pht(
283 '(+%s more...)',
284 new PhutilNumber(count($events) - 1));
285 $subject = "{$subject} {$more}";
288 $calendar_phid = id(new PhabricatorCalendarApplication())
289 ->getPHID();
291 return id(new PhabricatorMetaMTAMail())
292 ->setSubject($subject)
293 ->addTos(array($viewer->getPHID()))
294 ->setSensitiveContent(false)
295 ->setFrom($calendar_phid)
296 ->setIsBulk(true)
297 ->setSubjectPrefix(pht('[Calendar]'))
298 ->setVarySubjectPrefix(pht('[Reminder]'))
299 ->setThreadID($next_event->getPHID(), false)
300 ->setRelatedPHID($next_event->getPHID())
301 ->setBody($body->render())
302 ->setHTMLBody($body->renderHTML());