diff --git a/README.md b/README.md index 16fa3dced..2cb6a8baa 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ The special E2E client settings should be defined in `env.yml`: | OTP_TIMEZONE | string | Required | America/Los_Angeles | The timezone identifier that OTP is using to parse dates and times. OTP will use the timezone identifier that it finds in the first available agency to parse dates and times. | | OTP_UI_NAME | string | Optional | Trip Planner | Config setting for linking to the OTP UI (trip planner). | | OTP_UI_URL | string | Optional | https://plan.example.com | Config setting for linking to the OTP UI (trip planner). | +| PUSH_API_KEY | string | Optional | your-api-key | Key for Mobile Team push notifications internal API. | +| PUSH_API_URL | string | Optional | https://example.com/api/otp_push/sound_transit | URL for Mobile Team push notifications internal API. | | SERVICE_DAY_START_HOUR | integer | Optional | 3 | Optional parameter for the hour (local time, 24-hr format) at which a service day starts. To make the service day change at 2am, enter 2. The default is 3am. | | SPARKPOST_KEY | string | Optional | your-api-key | Get Sparkpost key at: https://app.sparkpost.com/account/api-keys | | TWILIO_ACCOUNT_SID | string | Optional | your-account-sid | Twilio settings available at: https://twilio.com/user/account | diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java index 15eb9a9d3..6bb536f8f 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java @@ -112,7 +112,7 @@ public VerificationResult sendVerificationText(Request req, Response res) { if (verification.getStatus().equals("pending")) { otpUser.phoneNumber = phoneNumber; otpUser.isPhoneNumberVerified = false; - otpUser.notificationChannel = "sms"; + otpUser.notificationChannel.add(OtpUser.Notification.SMS); Persistence.otpUsers.replace(otpUser.id, otpUser); } diff --git a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java index 5d793896e..7fd236861 100644 --- a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java @@ -1,14 +1,17 @@ package org.opentripplanner.middleware.models; +import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; import org.opentripplanner.middleware.auth.Auth0Users; import org.opentripplanner.middleware.auth.RequestingUser; import org.opentripplanner.middleware.persistence.Persistence; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * This represents a user of an OpenTripPlanner instance (typically of the standard OTP UI/otp-react-redux). @@ -16,6 +19,10 @@ * can also opt-in to storing their trip planning requests/responses. */ public class OtpUser extends AbstractUser { + public enum Notification { + EMAIL, PUSH, SMS + } + public static final String AUTH0_SCOPE = "otp-user"; private static final long serialVersionUID = 1L; private static final Logger LOG = LoggerFactory.getLogger(OtpUser.class); @@ -30,11 +37,10 @@ public class OtpUser extends AbstractUser { public boolean isPhoneNumberVerified; /** - * Notification preference for this user - * ("email", "sms", or "none"). - * TODO: Convert to enum. See http://mongodb.github.io/mongo-java-driver/3.7/bson/pojos/ for guidance. + * Notification preferences for this user + * (EMAIL and/or SMS and/or PUSH). */ - public String notificationChannel; + public EnumSet notificationChannel = EnumSet.noneOf(OtpUser.Notification.class); /** * Verified phone number for SMS notifications, in +15551234 format (E.164 format, includes country code, no spaces). @@ -47,6 +53,11 @@ public class OtpUser extends AbstractUser { */ public String preferredLocale; + /** + * Number of push devices associated with user email + */ + public int pushDevices; + /** Locations that the user has saved. */ public List savedLocations = new ArrayList<>(); @@ -105,4 +116,35 @@ public boolean canBeManagedBy(RequestingUser requestingUser) { return super.canBeManagedBy(requestingUser); } + /** + * Get notification channels as comma-separated list in one string + */ + @JsonGetter(value = "notificationChannel") + public String getNotificationChannel() { + return notificationChannel.stream() + .map(channel -> channel.name().toLowerCase()) + .collect(Collectors.joining(",")); + } + + /** + * Set notification channels based on comma-separated list in one string + */ + @JsonSetter(value = "notificationChannel") + public void setNotificationChannel(String channels) { + if (channels.isEmpty() || "none".equals(channels)) { + notificationChannel.clear(); + } else { + Stream.of(channels.split(",")) + .filter(Objects::nonNull) + .map(str -> str.trim().toUpperCase()) + .filter(str -> !str.isEmpty()) + .forEach(channel -> { + try { + notificationChannel.add(Enum.valueOf(OtpUser.Notification.class, channel)); + } catch (Exception e) { + LOG.error("Notification channel \"{}\" is not valid", channel); + } + }); + } + } } diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java index 8ba7d47ac..5c3f15f68 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java @@ -378,17 +378,23 @@ public TripMonitorNotification checkTripForDelay(NotificationType delayType) { * preferences. */ private void sendNotifications() { - if (notifications.size() == 0) { - // FIXME: Change log level - LOG.info("No notifications queued for trip. Skipping notify."); - return; - } OtpUser otpUser = Persistence.otpUsers.getById(trip.userId); if (otpUser == null) { LOG.error("Cannot find user for id {}", trip.userId); // TODO: Bugsnag / delete monitored trip? return; } + // Update push notification devices count, which may change asynchronously + int numPushDevices = NotificationUtils.getPushInfo(otpUser.email); + if (numPushDevices != otpUser.pushDevices) { + otpUser.pushDevices = numPushDevices; + Persistence.otpUsers.replace(otpUser.id, otpUser); + } + if (notifications.size() == 0) { + // FIXME: Change log level + LOG.info("No notifications queued for trip. Skipping notify."); + return; + } // If the same notifications were just sent, there is no need to send the same notification. // TODO: Should there be some time threshold check here based on lastNotificationTime? if (previousJourneyState.lastNotifications.containsAll(notifications)) { @@ -403,23 +409,22 @@ private void sendNotifications() { ); // FIXME: Change log level LOG.info("Sending notification to user {}", trip.userId); - boolean success = false; - // FIXME: This needs to be an enum. - switch (otpUser.notificationChannel.toLowerCase()) { - case "sms": - success = sendSMS(otpUser, templateData); - break; - case "email": - success = sendEmail(otpUser, templateData); - break; - case "all": - // TODO better handle below when one of the following fails - success = sendSMS(otpUser, templateData) && sendEmail(otpUser, templateData); - break; - default: - break; + boolean successEmail = false; + boolean successPush = false; + boolean successSms = false; + + if (otpUser.notificationChannel.contains(OtpUser.Notification.EMAIL)) { + successEmail = sendEmail(otpUser, templateData); + } + if (otpUser.notificationChannel.contains(OtpUser.Notification.PUSH)) { + successPush = sendPush(otpUser, templateData); } - if (success) { + if (otpUser.notificationChannel.contains(OtpUser.Notification.SMS)) { + successSms = sendSMS(otpUser, templateData); + } + + // TODO: better handle below when one of the following fails + if (successEmail || successPush || successSms) { notificationTimestampMillis = DateTimeUtils.currentTimeMillis(); } } @@ -431,6 +436,13 @@ private boolean sendSMS(OtpUser otpUser, Map data) { return NotificationUtils.sendSMS(otpUser, "MonitoredTripSms.ftl", data) != null; } + /** + * Send push notification. + */ + private boolean sendPush(OtpUser otpUser, Map data) { + return NotificationUtils.sendPush(otpUser, "MonitoredTripText.ftl", data) != null; + } + /** * Send notification email in MonitoredTrip template. */ diff --git a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java index 7791bc11c..4b42d9efd 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java @@ -9,18 +9,23 @@ import com.twilio.rest.verify.v2.service.VerificationCreator; import com.twilio.type.PhoneNumber; import freemarker.template.TemplateException; +import org.eclipse.jetty.http.HttpMethod; import org.opentripplanner.middleware.bugsnag.BugsnagReporter; import org.opentripplanner.middleware.models.AdminUser; import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.utils.HttpUtils; +import org.opentripplanner.middleware.utils.JsonUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.net.URI; +import java.util.Map; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText; /** - * This class contains utils for sending SMS and email notifications. + * This class contains utils for sending SMS, email, and push notifications. * * TODO: It may be better to initialize all of these notification clients in a static block? This may not really be * necessary though -- needs some more research. @@ -36,6 +41,55 @@ public class NotificationUtils { private static final String SPARKPOST_KEY = getConfigPropertyAsText("SPARKPOST_KEY"); private static final String FROM_EMAIL = getConfigPropertyAsText("NOTIFICATION_FROM_EMAIL"); public static final String OTP_ADMIN_DASHBOARD_FROM_EMAIL = getConfigPropertyAsText("OTP_ADMIN_DASHBOARD_FROM_EMAIL"); + private static final String PUSH_API_KEY = getConfigPropertyAsText("PUSH_API_KEY"); + private static final String PUSH_API_URL = getConfigPropertyAsText("PUSH_API_URL"); + + /** + * @param otpUser target user + * @param textTemplate template to use for email in text format + * @param templateData template data + */ + public static String sendPush(OtpUser otpUser, String textTemplate, Object templateData) { + // If Push API config properties aren't set, do nothing. + if (PUSH_API_KEY == null || PUSH_API_URL == null) return null; + try { + String body = TemplateUtils.renderTemplate(textTemplate, templateData); + String toUser = otpUser.email; + return otpUser.pushDevices > 0 ? sendPush(toUser, body) : "OK"; + } catch (TemplateException | IOException e) { + // This catch indicates there was an error rendering the template. Note: TemplateUtils#renderTemplate + // handles Bugsnag reporting/error logging, so that is not needed here. + return null; + } + } + + /** + * Send a push notification message to the provided user + * @param toUser user account ID (email address) + * @param body message body + * @return "OK" if message was successful (null otherwise) + */ + static String sendPush(String toUser, String body) { + try { + var jsonBody = "{\"user\":\"" + toUser + "\",\"message\":\"" + body + "\"}"; + Map headers = Map.of("Accept", "application/json"); + var httpResponse = HttpUtils.httpRequestRawResponse( + URI.create(PUSH_API_URL + "/notification/publish?api_key=" + PUSH_API_KEY), + 1000, + HttpMethod.POST, + headers, + jsonBody + ); + if (httpResponse.status == 200) { + return "OK"; + } else { + LOG.error("Error {} while trying to initiate push notification", httpResponse.status); + } + } catch (Exception e) { + LOG.error("Could not initiate push notification", e); + } + return null; + } /** * Send templated SMS to {@link OtpUser}'s verified phone number. @@ -222,5 +276,37 @@ public static boolean sendEmailViaSparkpost( return false; } } -} + /** + * Get number of push notification devices. Calls Push API's get endpoint, the only reliable way + * to obtain this value, as the publish endpoint returns success even for zero devices. + * + * @param toUser email address of user that devices are indexed by + * @return number of devices registered, 0 can mean zero devices or an error obtaining the number + */ + public static int getPushInfo(String toUser) { + // If Push API config properties aren't set, no info can be obtained. + if (PUSH_API_KEY == null || PUSH_API_URL == null) return 0; + try { + Map headers = Map.of("Accept", "application/json"); + var httpResponse = HttpUtils.httpRequestRawResponse( + URI.create(PUSH_API_URL + "/devices/get?api_key=" + PUSH_API_KEY + "&user=" + toUser), + 1000, + HttpMethod.GET, + headers, + null + ); + if (httpResponse.status == 200) { + // We don't use any of this information, we only care how many devices are registered. + var devices = JsonUtils.getPOJOFromHttpBodyAsList(httpResponse, Object.class); + return devices.size(); + } else { + LOG.error("Error {} while getting info on push notification devices", httpResponse.status); + } + } catch (Exception e) { + LOG.error("No info on push notification devices", e); + } + return 0; + } + +} diff --git a/src/main/resources/env.schema.json b/src/main/resources/env.schema.json index 134b54883..88d186756 100644 --- a/src/main/resources/env.schema.json +++ b/src/main/resources/env.schema.json @@ -202,6 +202,16 @@ "examples": ["https://plan.example.com"], "description": "Config setting for linking to the OTP UI (trip planner)." }, + "PUSH_API_KEY": { + "type": "string", + "examples": ["your-api-key"], + "description": "Key for Mobile Team push notifications internal API." + }, + "PUSH_API_URL": { + "type": "string", + "examples": ["https://example.com/api/otp_push/sound_transit"], + "description": "URL for Mobile Team push notifications internal API." + }, "SERVICE_DAY_START_HOUR": { "type": "integer", "examples": ["3"], diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 2689cf6f4..5ea445db3 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -2548,11 +2548,20 @@ definitions: isPhoneNumberVerified: type: "boolean" notificationChannel: - type: "string" + type: "array" + items: + type: "string" + enum: + - "EMAIL" + - "PUSH" + - "SMS" phoneNumber: type: "string" preferredLocale: type: "string" + pushDevices: + type: "integer" + format: "int32" savedLocations: type: "array" items: diff --git a/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java b/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java index 34c595d43..aa43ba3be 100644 --- a/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java +++ b/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java @@ -41,9 +41,10 @@ public static OtpUser createUser(String email, String phoneNumber) { OtpUser user = new OtpUser(); user.email = email; user.phoneNumber = phoneNumber; - user.notificationChannel = "email"; + user.notificationChannel.add(OtpUser.Notification.EMAIL); user.hasConsentedToTerms = true; user.storeTripHistory = true; + user.pushDevices = 0; Persistence.otpUsers.create(user); return user; } diff --git a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java index 5745e56e2..261adced1 100644 --- a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java +++ b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java @@ -20,9 +20,9 @@ import static org.opentripplanner.middleware.utils.NotificationUtils.OTP_ADMIN_DASHBOARD_FROM_EMAIL; /** - * Contains tests for the various notification utilities to send SMS and email messages. Note: these tests require the - * environment variables RUN_E2E=true and valid values for TEST_TO_EMAIL and TEST_TO_PHONE. Furthermore, TEST_TO_PHONE - * must be a verified phone number in a valid Twilio account. + * Contains tests for the various notification utilities to send SMS, email messages, and push notifications. + * Note: these tests require the environment variables RUN_E2E=true and valid values for TEST_TO_EMAIL, TEST_TO_PHONE, + * and TEST_TO_PUSH. Furthermore, TEST_TO_PHONE must be a verified phone number in a valid Twilio account. */ public class NotificationUtilsTest extends OtpMiddlewareTestEnvironment { private static final Logger LOG = LoggerFactory.getLogger(NotificationUtilsTest.class); @@ -35,10 +35,13 @@ public class NotificationUtilsTest extends OtpMiddlewareTestEnvironment { private static final String email = System.getenv("TEST_TO_EMAIL"); /** Phone must be in the form "+15551234" and must be verified first in order to send notifications */ private static final String phone = System.getenv("TEST_TO_PHONE"); + /** Push notification is conventionally a user.email value and must be known to the mobile team's push API */ + private static final String push = System.getenv("TEST_TO_PUSH"); /** * Currently, since these tests require target email/SMS values, these tests should not run on CI. */ - private static final boolean shouldTestsRun = !isRunningCi && IS_END_TO_END && email != null && phone != null; + private static final boolean shouldTestsRun = + !isRunningCi && IS_END_TO_END && email != null && phone != null && push != null; @BeforeAll public static void setup() throws IOException { @@ -51,6 +54,17 @@ public static void tearDown() { if (user != null) Persistence.otpUsers.removeById(user.id); } + @Test + public void canSendPushNotification() { + String ret = NotificationUtils.sendPush( + // Conventionally user.email + push, + "Tough little ship!" + ); + LOG.info("Push notification (ret={}) sent to {}", ret, push); + Assertions.assertNotNull(ret); + } + @Test public void canSendSparkpostEmailNotification() { boolean success = NotificationUtils.sendEmailViaSparkpost(