From 6e6ae36dcf935188257259cabc776e5c2be241ed Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 31 Oct 2016 18:01:33 -0700 Subject: [PATCH] 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 --- bin/calendar | 1 + .../20161031.calendar.02.notifylog.sql | 8 + scripts/setup/manage_calendar.php | 21 ++ src/__phutil_library_map__.php | 8 + ...icatorCalendarManagementNotifyWorkflow.php | 25 +++ .../PhabricatorCalendarManagementWorkflow.php | 4 + .../PhabricatorCalendarNotificationEngine.php | 200 ++++++++++++++++++ .../query/PhabricatorCalendarEventQuery.php | 18 ++ .../storage/PhabricatorCalendarEvent.php | 13 ++ .../PhabricatorCalendarNotification.php | 27 +++ 10 files changed, 325 insertions(+) create mode 120000 bin/calendar create mode 100644 resources/sql/autopatches/20161031.calendar.02.notifylog.sql create mode 100755 scripts/setup/manage_calendar.php create mode 100644 src/applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php create mode 100644 src/applications/calendar/management/PhabricatorCalendarManagementWorkflow.php create mode 100644 src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php create mode 100644 src/applications/calendar/storage/PhabricatorCalendarNotification.php diff --git a/bin/calendar b/bin/calendar new file mode 120000 index 0000000000..33929a5ba1 --- /dev/null +++ b/bin/calendar @@ -0,0 +1 @@ +../scripts/setup/manage_calendar.php \ No newline at end of file diff --git a/resources/sql/autopatches/20161031.calendar.02.notifylog.sql b/resources/sql/autopatches/20161031.calendar.02.notifylog.sql new file mode 100644 index 0000000000..0c6b1b6a80 --- /dev/null +++ b/resources/sql/autopatches/20161031.calendar.02.notifylog.sql @@ -0,0 +1,8 @@ +CREATE TABLE {$NAMESPACE}_calendar.calendar_notification ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + eventPHID VARBINARY(64) NOT NULL, + utcInitialEpoch INT UNSIGNED NOT NULL, + targetPHID VARBINARY(64) NOT NULL, + didNotifyEpoch INT UNSIGNED NOT NULL, + UNIQUE KEY `key_notify` (eventPHID, utcInitialEpoch, targetPHID) +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/scripts/setup/manage_calendar.php b/scripts/setup/manage_calendar.php new file mode 100755 index 0000000000..135c42ded1 --- /dev/null +++ b/scripts/setup/manage_calendar.php @@ -0,0 +1,21 @@ +#!/usr/bin/env php +setTagline(pht('manage Calendar')); +$args->setSynopsis(<<parseStandardArguments(); + +$workflows = id(new PhutilClassMapQuery()) + ->setAncestorClass('PhabricatorCalendarManagementWorkflow') + ->execute(); +$workflows[] = new PhutilHelpArgumentWorkflow(); +$args->parseWorkflows($workflows); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a7d250eba3..a9cf5ef81d 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2153,6 +2153,10 @@ phutil_register_library_map(array( 'PhabricatorCalendarImportTriggerLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportTriggerLogType.php', 'PhabricatorCalendarImportUpdateLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportUpdateLogType.php', 'PhabricatorCalendarImportViewController' => 'applications/calendar/controller/PhabricatorCalendarImportViewController.php', + 'PhabricatorCalendarManagementNotifyWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php', + 'PhabricatorCalendarManagementWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementWorkflow.php', + 'PhabricatorCalendarNotification' => 'applications/calendar/storage/PhabricatorCalendarNotification.php', + 'PhabricatorCalendarNotificationEngine' => 'applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php', 'PhabricatorCalendarRemarkupRule' => 'applications/calendar/remarkup/PhabricatorCalendarRemarkupRule.php', 'PhabricatorCalendarReplyHandler' => 'applications/calendar/mail/PhabricatorCalendarReplyHandler.php', 'PhabricatorCalendarSchemaSpec' => 'applications/calendar/storage/PhabricatorCalendarSchemaSpec.php', @@ -7014,6 +7018,10 @@ phutil_register_library_map(array( 'PhabricatorCalendarImportTriggerLogType' => 'PhabricatorCalendarImportLogType', 'PhabricatorCalendarImportUpdateLogType' => 'PhabricatorCalendarImportLogType', 'PhabricatorCalendarImportViewController' => 'PhabricatorCalendarController', + 'PhabricatorCalendarManagementNotifyWorkflow' => 'PhabricatorCalendarManagementWorkflow', + 'PhabricatorCalendarManagementWorkflow' => 'PhabricatorManagementWorkflow', + 'PhabricatorCalendarNotification' => 'PhabricatorCalendarDAO', + 'PhabricatorCalendarNotificationEngine' => 'Phobject', 'PhabricatorCalendarRemarkupRule' => 'PhabricatorObjectRemarkupRule', 'PhabricatorCalendarReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler', 'PhabricatorCalendarSchemaSpec' => 'PhabricatorConfigSchemaSpec', diff --git a/src/applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php b/src/applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php new file mode 100644 index 0000000000..1c0c9894a9 --- /dev/null +++ b/src/applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php @@ -0,0 +1,25 @@ +setName('notify') + ->setExamples('**notify** [options]') + ->setSynopsis( + pht( + 'Test and debug notifications about upcoming events.')) + ->setArguments(array()); + } + + public function execute(PhutilArgumentParser $args) { + $viewer = $this->getViewer(); + + $engine = new PhabricatorCalendarNotificationEngine(); + $engine->publishNotifications(); + + return 0; + } + +} diff --git a/src/applications/calendar/management/PhabricatorCalendarManagementWorkflow.php b/src/applications/calendar/management/PhabricatorCalendarManagementWorkflow.php new file mode 100644 index 0000000000..613a0bab64 --- /dev/null +++ b/src/applications/calendar/management/PhabricatorCalendarManagementWorkflow.php @@ -0,0 +1,4 @@ +cursor) { + $now = PhabricatorTime::getNow(); + $this->cursor = $now - phutil_units('5 minutes in seconds'); + } + + return $this->cursor; + } + + public function publishNotifications() { + $cursor = $this->getCursor(); + + $window_min = $cursor - phutil_units('16 hours in seconds'); + $window_max = $cursor + phutil_units('16 hours in seconds'); + + $viewer = PhabricatorUser::getOmnipotentUser(); + + $events = id(new PhabricatorCalendarEventQuery()) + ->setViewer($viewer) + ->withDateRange($window_min, $window_max) + ->withIsCancelled(false) + ->withIsImported(false) + ->setGenerateGhosts(true) + ->execute(); + if (!$events) { + // No events are starting soon in any timezone, so there is nothing + // left to be done. + return; + } + + $attendee_map = array(); + foreach ($events as $key => $event) { + $notifiable_phids = array(); + foreach ($event->getInvitees() as $invitee) { + if (!$invitee->isAttending()) { + continue; + } + $notifiable_phids[] = $invitee->getInviteePHID(); + } + if (!$notifiable_phids) { + unset($events[$key]); + } + $attendee_map[$key] = array_fuse($notifiable_phids); + } + if (!$attendee_map) { + // None of the events have any notifiable attendees, so there is no + // one to notify of anything. + return; + } + + $all_attendees = array(); + foreach ($attendee_map as $key => $attendee_phids) { + foreach ($attendee_phids as $attendee_phid) { + $all_attendees[$attendee_phid] = $attendee_phid; + } + } + + $user_map = id(new PhabricatorPeopleQuery()) + ->setViewer($viewer) + ->withPHIDs($all_attendees) + ->withIsDisabled(false) + ->needUserSettings(true) + ->execute(); + $user_map = mpull($user_map, null, 'getPHID'); + if (!$user_map) { + // None of the attendees are valid users: they're all imported users + // or projects or invalid or some other kind of unnotifiable entity. + return; + } + + $all_event_phids = array(); + foreach ($events as $key => $event) { + foreach ($event->getNotificationPHIDs() as $phid) { + $all_event_phids[$phid] = $phid; + } + } + + $table = new PhabricatorCalendarNotification(); + $conn = $table->establishConnection('w'); + + $rows = queryfx_all( + $conn, + 'SELECT * FROM %T WHERE eventPHID IN (%Ls) AND targetPHID IN (%Ls)', + $table->getTableName(), + $all_event_phids, + $all_attendees); + $sent_map = array(); + foreach ($rows as $row) { + $event_phid = $row['eventPHID']; + $target_phid = $row['targetPHID']; + $initial_epoch = $row['utcInitialEpoch']; + $sent_map[$event_phid][$target_phid][$initial_epoch] = $row; + } + + $notify_min = $cursor; + $notify_max = $cursor + phutil_units('15 minutes in seconds'); + $notify_map = array(); + foreach ($events as $key => $event) { + $initial_epoch = $event->getUTCInitialEpoch(); + $event_phids = $event->getNotificationPHIDs(); + + // Select attendees who actually exist, and who we have not sent any + // notifications to yet. + $attendee_phids = $attendee_map[$key]; + $users = array_select_keys($user_map, $attendee_phids); + foreach ($users as $user_phid => $user) { + foreach ($event_phids as $event_phid) { + if (isset($sent_map[$event_phid][$user_phid][$initial_epoch])) { + unset($users[$user_phid]); + continue 2; + } + } + } + + if (!$users) { + continue; + } + + // Discard attendees for whom the event start time isn't soon. Events + // may start at different times for different users, so we need to + // check every user's start time. + foreach ($users as $user_phid => $user) { + $user_datetime = $event->newStartDateTime() + ->setViewerTimezone($user->getTimezoneIdentifier()); + + $user_epoch = $user_datetime->getEpoch(); + if ($user_epoch < $notify_min || $user_epoch > $notify_max) { + unset($users[$user_phid]); + continue; + } + + $notify_map[$user_phid][] = array( + 'event' => $event, + 'datetime' => $user_datetime, + 'epoch' => $user_epoch, + ); + } + } + + $mail_list = array(); + $mark_list = array(); + $now = PhabricatorTime::getNow(); + foreach ($notify_map as $user_phid => $events) { + $user = $user_map[$user_phid]; + $events = isort($events, 'epoch'); + + // TODO: This is just a proof-of-concept that gets dumped to the console; + // it will be replaced with a nice fancy email and notification. + + $body = array(); + $body[] = pht('%s, these events start soon:', $user->getUsername()); + $body[] = null; + foreach ($events as $spec) { + $event = $spec['event']; + $body[] = $event->getName(); + } + $body = implode("\n", $body); + + $mail_list[] = $body; + + foreach ($events as $spec) { + $event = $spec['event']; + foreach ($event->getNotificationPHIDs() as $phid) { + $mark_list[] = qsprintf( + $conn, + '(%s, %s, %d, %d)', + $phid, + $user_phid, + $event->getUTCInitialEpoch(), + $now); + } + } + } + + // Mark all the notifications we're about to send as delivered so we + // do not double-notify. + foreach (PhabricatorLiskDAO::chunkSQL($mark_list) as $chunk) { + queryfx( + $conn, + 'INSERT IGNORE INTO %T + (eventPHID, targetPHID, utcInitialEpoch, didNotifyEpoch) + VALUES %Q', + $table->getTableName(), + $chunk); + } + + foreach ($mail_list as $mail) { + echo $mail; + echo "\n\n"; + } + } + +} diff --git a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php index a6ac2e4ffc..3980c0635d 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php @@ -19,6 +19,7 @@ final class PhabricatorCalendarEventQuery private $importUIDs; private $utcInitialEpochMin; private $utcInitialEpochMax; + private $isImported; private $generateGhosts = false; @@ -103,6 +104,11 @@ final class PhabricatorCalendarEventQuery return $this; } + public function withIsImported($is_imported) { + $this->isImported = $is_imported; + return $this; + } + protected function getDefaultOrderVector() { return array('start', 'id'); } @@ -472,6 +478,18 @@ final class PhabricatorCalendarEventQuery $this->importUIDs); } + if ($this->isImported !== null) { + if ($this->isImported) { + $where[] = qsprintf( + $conn, + 'event.importSourcePHID IS NOT NULL'); + } else { + $where[] = qsprintf( + $conn, + 'event.importSourcePHID IS NULL'); + } + } + return $where; } diff --git a/src/applications/calendar/storage/PhabricatorCalendarEvent.php b/src/applications/calendar/storage/PhabricatorCalendarEvent.php index 07e041a9ce..c40afe443e 100644 --- a/src/applications/calendar/storage/PhabricatorCalendarEvent.php +++ b/src/applications/calendar/storage/PhabricatorCalendarEvent.php @@ -1124,6 +1124,19 @@ final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO ->execute(); } + public function getNotificationPHIDs() { + $phids = array(); + if ($this->getPHID()) { + $phids[] = $this->getPHID(); + } + + if ($this->getSeriesParentPHID()) { + $phids[] = $this->getSeriesParentPHID(); + } + + return $phids; + } + /* -( Markup Interface )--------------------------------------------------- */ diff --git a/src/applications/calendar/storage/PhabricatorCalendarNotification.php b/src/applications/calendar/storage/PhabricatorCalendarNotification.php new file mode 100644 index 0000000000..46daaa2b3a --- /dev/null +++ b/src/applications/calendar/storage/PhabricatorCalendarNotification.php @@ -0,0 +1,27 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'utcInitialEpoch' => 'epoch', + 'didNotifyEpoch' => 'epoch', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_notify' => array( + 'columns' => array('eventPHID', 'utcInitialEpoch', 'targetPHID'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + +}