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(); + } + +}