Skip to content

Commit 6e6ae36

Browse files
author
epriestley
committed
Add a skeleton for Calendar notifications
Summary: Ref T7931. I'm going to do this separate from existing infrastructure because: - events start at different times for different users; - I like the idea of being able to batch stuff (send one email about several upcoming events); - triggering on ghost/recurring events is a real complicated mess. This puts a skeleton in place that finds all the events we need to notify about and writes some silly example bodies to stdout, marking that we notified users so they don't get notified again. Test Plan: Ran `bin/calendar notify`, got a "great" notification in the command output. {F1891625} Reviewers: chad Reviewed By: chad Maniphest Tasks: T7931 Differential Revision: https://secure.phabricator.com/D16783
1 parent a0ea31f commit 6e6ae36

10 files changed

+325
-0
lines changed

bin/calendar

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../scripts/setup/manage_calendar.php
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TABLE {$NAMESPACE}_calendar.calendar_notification (
2+
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
3+
eventPHID VARBINARY(64) NOT NULL,
4+
utcInitialEpoch INT UNSIGNED NOT NULL,
5+
targetPHID VARBINARY(64) NOT NULL,
6+
didNotifyEpoch INT UNSIGNED NOT NULL,
7+
UNIQUE KEY `key_notify` (eventPHID, utcInitialEpoch, targetPHID)
8+
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

scripts/setup/manage_calendar.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
$root = dirname(dirname(dirname(__FILE__)));
5+
require_once $root.'/scripts/__init_script__.php';
6+
7+
$args = new PhutilArgumentParser($argv);
8+
$args->setTagline(pht('manage Calendar'));
9+
$args->setSynopsis(<<<EOSYNOPSIS
10+
**calendar** __command__ [__options__]
11+
Manage Calendar.
12+
13+
EOSYNOPSIS
14+
);
15+
$args->parseStandardArguments();
16+
17+
$workflows = id(new PhutilClassMapQuery())
18+
->setAncestorClass('PhabricatorCalendarManagementWorkflow')
19+
->execute();
20+
$workflows[] = new PhutilHelpArgumentWorkflow();
21+
$args->parseWorkflows($workflows);

src/__phutil_library_map__.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,10 @@
21532153
'PhabricatorCalendarImportTriggerLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportTriggerLogType.php',
21542154
'PhabricatorCalendarImportUpdateLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportUpdateLogType.php',
21552155
'PhabricatorCalendarImportViewController' => 'applications/calendar/controller/PhabricatorCalendarImportViewController.php',
2156+
'PhabricatorCalendarManagementNotifyWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php',
2157+
'PhabricatorCalendarManagementWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementWorkflow.php',
2158+
'PhabricatorCalendarNotification' => 'applications/calendar/storage/PhabricatorCalendarNotification.php',
2159+
'PhabricatorCalendarNotificationEngine' => 'applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php',
21562160
'PhabricatorCalendarRemarkupRule' => 'applications/calendar/remarkup/PhabricatorCalendarRemarkupRule.php',
21572161
'PhabricatorCalendarReplyHandler' => 'applications/calendar/mail/PhabricatorCalendarReplyHandler.php',
21582162
'PhabricatorCalendarSchemaSpec' => 'applications/calendar/storage/PhabricatorCalendarSchemaSpec.php',
@@ -7014,6 +7018,10 @@
70147018
'PhabricatorCalendarImportTriggerLogType' => 'PhabricatorCalendarImportLogType',
70157019
'PhabricatorCalendarImportUpdateLogType' => 'PhabricatorCalendarImportLogType',
70167020
'PhabricatorCalendarImportViewController' => 'PhabricatorCalendarController',
7021+
'PhabricatorCalendarManagementNotifyWorkflow' => 'PhabricatorCalendarManagementWorkflow',
7022+
'PhabricatorCalendarManagementWorkflow' => 'PhabricatorManagementWorkflow',
7023+
'PhabricatorCalendarNotification' => 'PhabricatorCalendarDAO',
7024+
'PhabricatorCalendarNotificationEngine' => 'Phobject',
70177025
'PhabricatorCalendarRemarkupRule' => 'PhabricatorObjectRemarkupRule',
70187026
'PhabricatorCalendarReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler',
70197027
'PhabricatorCalendarSchemaSpec' => 'PhabricatorConfigSchemaSpec',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
final class PhabricatorCalendarManagementNotifyWorkflow
4+
extends PhabricatorCalendarManagementWorkflow {
5+
6+
protected function didConstruct() {
7+
$this
8+
->setName('notify')
9+
->setExamples('**notify** [options]')
10+
->setSynopsis(
11+
pht(
12+
'Test and debug notifications about upcoming events.'))
13+
->setArguments(array());
14+
}
15+
16+
public function execute(PhutilArgumentParser $args) {
17+
$viewer = $this->getViewer();
18+
19+
$engine = new PhabricatorCalendarNotificationEngine();
20+
$engine->publishNotifications();
21+
22+
return 0;
23+
}
24+
25+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?php
2+
3+
abstract class PhabricatorCalendarManagementWorkflow
4+
extends PhabricatorManagementWorkflow {}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
final class PhabricatorCalendarNotificationEngine
4+
extends Phobject {
5+
6+
private $cursor;
7+
8+
public function getCursor() {
9+
if (!$this->cursor) {
10+
$now = PhabricatorTime::getNow();
11+
$this->cursor = $now - phutil_units('5 minutes in seconds');
12+
}
13+
14+
return $this->cursor;
15+
}
16+
17+
public function publishNotifications() {
18+
$cursor = $this->getCursor();
19+
20+
$window_min = $cursor - phutil_units('16 hours in seconds');
21+
$window_max = $cursor + phutil_units('16 hours in seconds');
22+
23+
$viewer = PhabricatorUser::getOmnipotentUser();
24+
25+
$events = id(new PhabricatorCalendarEventQuery())
26+
->setViewer($viewer)
27+
->withDateRange($window_min, $window_max)
28+
->withIsCancelled(false)
29+
->withIsImported(false)
30+
->setGenerateGhosts(true)
31+
->execute();
32+
if (!$events) {
33+
// No events are starting soon in any timezone, so there is nothing
34+
// left to be done.
35+
return;
36+
}
37+
38+
$attendee_map = array();
39+
foreach ($events as $key => $event) {
40+
$notifiable_phids = array();
41+
foreach ($event->getInvitees() as $invitee) {
42+
if (!$invitee->isAttending()) {
43+
continue;
44+
}
45+
$notifiable_phids[] = $invitee->getInviteePHID();
46+
}
47+
if (!$notifiable_phids) {
48+
unset($events[$key]);
49+
}
50+
$attendee_map[$key] = array_fuse($notifiable_phids);
51+
}
52+
if (!$attendee_map) {
53+
// None of the events have any notifiable attendees, so there is no
54+
// one to notify of anything.
55+
return;
56+
}
57+
58+
$all_attendees = array();
59+
foreach ($attendee_map as $key => $attendee_phids) {
60+
foreach ($attendee_phids as $attendee_phid) {
61+
$all_attendees[$attendee_phid] = $attendee_phid;
62+
}
63+
}
64+
65+
$user_map = id(new PhabricatorPeopleQuery())
66+
->setViewer($viewer)
67+
->withPHIDs($all_attendees)
68+
->withIsDisabled(false)
69+
->needUserSettings(true)
70+
->execute();
71+
$user_map = mpull($user_map, null, 'getPHID');
72+
if (!$user_map) {
73+
// None of the attendees are valid users: they're all imported users
74+
// or projects or invalid or some other kind of unnotifiable entity.
75+
return;
76+
}
77+
78+
$all_event_phids = array();
79+
foreach ($events as $key => $event) {
80+
foreach ($event->getNotificationPHIDs() as $phid) {
81+
$all_event_phids[$phid] = $phid;
82+
}
83+
}
84+
85+
$table = new PhabricatorCalendarNotification();
86+
$conn = $table->establishConnection('w');
87+
88+
$rows = queryfx_all(
89+
$conn,
90+
'SELECT * FROM %T WHERE eventPHID IN (%Ls) AND targetPHID IN (%Ls)',
91+
$table->getTableName(),
92+
$all_event_phids,
93+
$all_attendees);
94+
$sent_map = array();
95+
foreach ($rows as $row) {
96+
$event_phid = $row['eventPHID'];
97+
$target_phid = $row['targetPHID'];
98+
$initial_epoch = $row['utcInitialEpoch'];
99+
$sent_map[$event_phid][$target_phid][$initial_epoch] = $row;
100+
}
101+
102+
$notify_min = $cursor;
103+
$notify_max = $cursor + phutil_units('15 minutes in seconds');
104+
$notify_map = array();
105+
foreach ($events as $key => $event) {
106+
$initial_epoch = $event->getUTCInitialEpoch();
107+
$event_phids = $event->getNotificationPHIDs();
108+
109+
// Select attendees who actually exist, and who we have not sent any
110+
// notifications to yet.
111+
$attendee_phids = $attendee_map[$key];
112+
$users = array_select_keys($user_map, $attendee_phids);
113+
foreach ($users as $user_phid => $user) {
114+
foreach ($event_phids as $event_phid) {
115+
if (isset($sent_map[$event_phid][$user_phid][$initial_epoch])) {
116+
unset($users[$user_phid]);
117+
continue 2;
118+
}
119+
}
120+
}
121+
122+
if (!$users) {
123+
continue;
124+
}
125+
126+
// Discard attendees for whom the event start time isn't soon. Events
127+
// may start at different times for different users, so we need to
128+
// check every user's start time.
129+
foreach ($users as $user_phid => $user) {
130+
$user_datetime = $event->newStartDateTime()
131+
->setViewerTimezone($user->getTimezoneIdentifier());
132+
133+
$user_epoch = $user_datetime->getEpoch();
134+
if ($user_epoch < $notify_min || $user_epoch > $notify_max) {
135+
unset($users[$user_phid]);
136+
continue;
137+
}
138+
139+
$notify_map[$user_phid][] = array(
140+
'event' => $event,
141+
'datetime' => $user_datetime,
142+
'epoch' => $user_epoch,
143+
);
144+
}
145+
}
146+
147+
$mail_list = array();
148+
$mark_list = array();
149+
$now = PhabricatorTime::getNow();
150+
foreach ($notify_map as $user_phid => $events) {
151+
$user = $user_map[$user_phid];
152+
$events = isort($events, 'epoch');
153+
154+
// TODO: This is just a proof-of-concept that gets dumped to the console;
155+
// it will be replaced with a nice fancy email and notification.
156+
157+
$body = array();
158+
$body[] = pht('%s, these events start soon:', $user->getUsername());
159+
$body[] = null;
160+
foreach ($events as $spec) {
161+
$event = $spec['event'];
162+
$body[] = $event->getName();
163+
}
164+
$body = implode("\n", $body);
165+
166+
$mail_list[] = $body;
167+
168+
foreach ($events as $spec) {
169+
$event = $spec['event'];
170+
foreach ($event->getNotificationPHIDs() as $phid) {
171+
$mark_list[] = qsprintf(
172+
$conn,
173+
'(%s, %s, %d, %d)',
174+
$phid,
175+
$user_phid,
176+
$event->getUTCInitialEpoch(),
177+
$now);
178+
}
179+
}
180+
}
181+
182+
// Mark all the notifications we're about to send as delivered so we
183+
// do not double-notify.
184+
foreach (PhabricatorLiskDAO::chunkSQL($mark_list) as $chunk) {
185+
queryfx(
186+
$conn,
187+
'INSERT IGNORE INTO %T
188+
(eventPHID, targetPHID, utcInitialEpoch, didNotifyEpoch)
189+
VALUES %Q',
190+
$table->getTableName(),
191+
$chunk);
192+
}
193+
194+
foreach ($mail_list as $mail) {
195+
echo $mail;
196+
echo "\n\n";
197+
}
198+
}
199+
200+
}

src/applications/calendar/query/PhabricatorCalendarEventQuery.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class PhabricatorCalendarEventQuery
1919
private $importUIDs;
2020
private $utcInitialEpochMin;
2121
private $utcInitialEpochMax;
22+
private $isImported;
2223

2324
private $generateGhosts = false;
2425

@@ -103,6 +104,11 @@ public function withImportUIDs(array $uids) {
103104
return $this;
104105
}
105106

107+
public function withIsImported($is_imported) {
108+
$this->isImported = $is_imported;
109+
return $this;
110+
}
111+
106112
protected function getDefaultOrderVector() {
107113
return array('start', 'id');
108114
}
@@ -472,6 +478,18 @@ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
472478
$this->importUIDs);
473479
}
474480

481+
if ($this->isImported !== null) {
482+
if ($this->isImported) {
483+
$where[] = qsprintf(
484+
$conn,
485+
'event.importSourcePHID IS NOT NULL');
486+
} else {
487+
$where[] = qsprintf(
488+
$conn,
489+
'event.importSourcePHID IS NULL');
490+
}
491+
}
492+
475493
return $where;
476494
}
477495

src/applications/calendar/storage/PhabricatorCalendarEvent.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,19 @@ public function loadFutureEvents(PhabricatorUser $viewer) {
11241124
->execute();
11251125
}
11261126

1127+
public function getNotificationPHIDs() {
1128+
$phids = array();
1129+
if ($this->getPHID()) {
1130+
$phids[] = $this->getPHID();
1131+
}
1132+
1133+
if ($this->getSeriesParentPHID()) {
1134+
$phids[] = $this->getSeriesParentPHID();
1135+
}
1136+
1137+
return $phids;
1138+
}
1139+
11271140

11281141
/* -( Markup Interface )--------------------------------------------------- */
11291142

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
final class PhabricatorCalendarNotification
4+
extends PhabricatorCalendarDAO {
5+
6+
protected $eventPHID;
7+
protected $utcInitialEpoch;
8+
protected $targetPHID;
9+
protected $didNotifyEpoch;
10+
11+
protected function getConfiguration() {
12+
return array(
13+
self::CONFIG_TIMESTAMPS => false,
14+
self::CONFIG_COLUMN_SCHEMA => array(
15+
'utcInitialEpoch' => 'epoch',
16+
'didNotifyEpoch' => 'epoch',
17+
),
18+
self::CONFIG_KEY_SCHEMA => array(
19+
'key_notify' => array(
20+
'columns' => array('eventPHID', 'utcInitialEpoch', 'targetPHID'),
21+
'unique' => true,
22+
),
23+
),
24+
) + parent::getConfiguration();
25+
}
26+
27+
}

0 commit comments

Comments
 (0)