diff --git a/README.md b/README.md index e96122532..d2627da9e 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ The special E2E client settings should be defined in `env.yml`: | TRIP_TRACKING_TRAM_ON_TRACK_RADIUS | integer | Optional | 100 | The threshold in meters below which travelling by tram is considered on track. | | TRIP_INSTRUCTION_IMMEDIATE_RADIUS | integer | Optional | 2 | The radius in meters under which an immediate instruction is given. | | TRIP_INSTRUCTION_UPCOMING_RADIUS | integer | Optional | 10 | The radius in meters under which an upcoming instruction is given. | +| TRIP_SURVEY_ID | string | Optional | abcdef123y | The ID of a survey (on the platform of your choice) for trip-related feedback. | +| TRIP_SURVEY_SUBDOMAIN | string | Optional | abcabc12a | The subdomain of a website where the trip-related surveys are administered. | | TWILIO_ACCOUNT_SID | string | Optional | your-account-sid | Twilio settings available at: https://twilio.com/user/account | | TRUSTED_COMPANION_CONFIRMATION_PAGE_URL | string | Optional | https://otp-server.example.com/trusted/confirmation | URL to the trusted companion confirmation page. This page should support handling an error URL parameter. | | TWILIO_AUTH_TOKEN | string | Optional | your-auth-token | Twilio settings available at: https://twilio.com/user/account | diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index 6665b1522..c7996a62e 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -100,6 +100,10 @@ TRIP_INSTRUCTION_IMMEDIATE_RADIUS: 2 # The radius in meters under which an upcoming instruction is given. TRIP_INSTRUCTION_UPCOMING_RADIUS: 10 +# Survey ID and domain that is offered after users complete certain trips. +TRIP_SURVEY_ID: abcdef123y +TRIP_SURVEY_SUBDOMAIN: abcabc12a + US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL: https://bus.notifier.example.com US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY: your-key US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES: agency_id:route_id diff --git a/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java b/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java index 72c0811f3..212d54f0a 100644 --- a/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java +++ b/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java @@ -23,6 +23,7 @@ import org.opentripplanner.middleware.otp.OtpVersion; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.tripmonitor.jobs.MonitorAllTripsJob; +import org.opentripplanner.middleware.triptracker.TripSurveySenderJob; import org.opentripplanner.middleware.utils.ConfigUtils; import org.opentripplanner.middleware.utils.HttpUtils; import org.opentripplanner.middleware.utils.Scheduler; @@ -85,6 +86,16 @@ public static void main(String[] args) throws IOException, InterruptedException 1, TimeUnit.MINUTES ); + + // Schedule recurring job for post-trip surveys, once every half-hour to catch recently completed trips. + // TODO: Determine whether this should go in some other process. + TripSurveySenderJob tripSurveySenderJob = new TripSurveySenderJob(); + Scheduler.scheduleJob( + tripSurveySenderJob, + 0, + 30, + TimeUnit.MINUTES + ); } } diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java index d6b7ba2cc..16b2b20de 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java @@ -56,6 +56,7 @@ public abstract class ApiController implements Endpoint { public static final int DEFAULT_OFFSET = 0; public static final String OFFSET_PARAM = "offset"; public static final String USER_ID_PARAM = "userId"; + public static final String ID_FIELD_NAME = "_id"; public static final ParameterDescriptor LIMIT = ParameterDescriptor.newBuilder() .withName(LIMIT_PARAM) @@ -219,7 +220,7 @@ private ResponseList getMany(Request req, Response res) { // will be limited to just the entity matching this Otp user. Bson filter = (requestingUser.apiUser != null) ? Filters.eq("applicationId", requestingUser.apiUser.id) - : Filters.eq("_id", requestingUser.otpUser.id); + : Filters.eq(ID_FIELD_NAME, requestingUser.otpUser.id); return persistence.getResponseList(filter, offset, limit); } else if (requestingUser.isAPIUser()) { // A user id must be provided if the request is being made by a third party user. diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java index 7089c8d0d..0ae2f0d00 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -21,6 +21,7 @@ import static io.github.manusant.ss.descriptor.MethodDescriptor.path; import static com.mongodb.client.model.Filters.eq; +import static org.opentripplanner.middleware.models.MonitoredTrip.USER_ID_FIELD_NAME; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsInt; import static org.opentripplanner.middleware.utils.HttpUtils.JSON_ONLY; import static org.opentripplanner.middleware.utils.JsonUtils.getPOJOFromRequestBody; @@ -197,7 +198,7 @@ private static ItineraryExistence checkItinerary(Request request, Response respo */ private void verifyBelowMaxNumTrips(String userId, Request request) { // filter monitored trip on user id to find out how many have already been saved - Bson filter = Filters.and(eq("userId", userId)); + Bson filter = Filters.and(eq(USER_ID_FIELD_NAME, userId)); long count = this.persistence.getCountFiltered(filter); if (count >= MAXIMUM_PERMITTED_MONITORED_TRIPS) { logMessageAndHalt( diff --git a/src/main/java/org/opentripplanner/middleware/i18n/Message.java b/src/main/java/org/opentripplanner/middleware/i18n/Message.java index c5c3271fa..1870b5dc1 100644 --- a/src/main/java/org/opentripplanner/middleware/i18n/Message.java +++ b/src/main/java/org/opentripplanner/middleware/i18n/Message.java @@ -46,7 +46,8 @@ public enum Message { TRIP_DELAY_MINUTES, TRIP_NOT_FOUND_NOTIFICATION, TRIP_NO_LONGER_POSSIBLE_NOTIFICATION, - TRIP_REMINDER_NOTIFICATION; + TRIP_REMINDER_NOTIFICATION, + TRIP_SURVEY_NOTIFICATION; private static final Logger LOG = LoggerFactory.getLogger(Message.class); diff --git a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java index dec7cb2e4..24c7e2014 100644 --- a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java @@ -36,6 +36,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class MonitoredTrip extends Model { + public static final String USER_ID_FIELD_NAME = "userId"; + /** * Mongo Id of the {@link OtpUser} who owns this monitored trip. */ diff --git a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java index 79de4104c..c42d90f6d 100644 --- a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java @@ -11,10 +11,12 @@ import java.util.ArrayList; +import java.util.Comparator; import java.util.Date; import java.util.EnumSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -33,6 +35,7 @@ public enum Notification { public static final String AUTH0_SCOPE = "otp-user"; private static final long serialVersionUID = 1L; private static final Logger LOG = LoggerFactory.getLogger(OtpUser.class); + public static final String TRIP_SURVEY_NOTIFICATIONS_FIELD = "tripSurveyNotifications"; /** Whether the user would like accessible routes by default. */ public boolean accessibilityRoutingByDefault; @@ -83,6 +86,9 @@ public enum Notification { /** Whether to store the user's trip history (user must opt in). */ public boolean storeTripHistory; + /** The trail of survey notifications sent for journeys completed by the user. */ + public List tripSurveyNotifications = new ArrayList<>(); + @JsonIgnore /** If this user was created by an {@link ApiUser}, this parameter will match the {@link ApiUser}'s id */ public String applicationId; @@ -193,4 +199,10 @@ public void setNotificationChannel(String channels) { }); } } + + /** Obtains the last trip survey notification sent. */ + public Optional findLastTripSurveyNotificationSent() { + if (tripSurveyNotifications == null) return Optional.empty(); + return tripSurveyNotifications.stream().max(Comparator.comparingLong(n -> n.timeSent.getTime())); + } } diff --git a/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java b/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java index a6b4ed5eb..f328f4647 100644 --- a/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java +++ b/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.triptracker.TrackingLocation; +import org.opentripplanner.middleware.triptracker.TripStatus; import java.util.ArrayList; import java.util.Date; @@ -26,6 +27,10 @@ public class TrackedJourney extends Model { public Map busNotificationMessages = new HashMap<>(); + public int longestConsecutiveDeviatedPoints = -1; + + public transient MonitoredTrip trip; + public static final String TRIP_ID_FIELD_NAME = "tripId"; public static final String LOCATIONS_FIELD_NAME = "locations"; @@ -35,6 +40,8 @@ public class TrackedJourney extends Model { public static final String END_CONDITION_FIELD_NAME = "endCondition"; + public static final String LONGEST_CONSECUTIVE_DEVIATED_POINTS_FIELD_NAME = "longestConsecutiveDeviatedPoints"; + public static final String TERMINATED_BY_USER = "Tracking terminated by user."; public static final String FORCIBLY_TERMINATED = "Tracking forcibly terminated."; @@ -91,4 +98,30 @@ public void updateNotificationMessage(String routeId, String body) { busNotificationMessages ); } + + /** The largest consecutive deviations for all tracking locations marked "deviated". */ + public int computeLargestConsecutiveDeviations() { + if (locations == null) return -1; + + int count = 0; + int maxCount = 0; + for (TrackingLocation location : locations) { + // A trip status must have been computed for a location to count. + // (The mobile app will send many other more for reference, but only those for which we compute a status + // (i.e. the last coordinate in every batch) will potentially count. + if (location.tripStatus != null) { + // Traveler must be moving (speed != 0) for a deviated location to be counted. + if (location.tripStatus == TripStatus.DEVIATED) { + if (location.speed != 0) { + count++; + if (maxCount < count) maxCount = count; + } + } else { + // If a location has a status computed and is not deviated, reset the streak. + count = 0; + } + } + } + return maxCount; + } } diff --git a/src/main/java/org/opentripplanner/middleware/models/TripSurveyNotification.java b/src/main/java/org/opentripplanner/middleware/models/TripSurveyNotification.java new file mode 100644 index 000000000..0d6ec8af0 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/models/TripSurveyNotification.java @@ -0,0 +1,32 @@ +package org.opentripplanner.middleware.models; + +import java.util.Date; +import java.util.UUID; + +/** Contains information regarding survey notifications sent after a trip is completed. */ +public class TripSurveyNotification { + + public static final String TIME_SENT_FIELD = "timeSent"; + + /** + * Unique ID to link a survey entry to the corresponding notification + * (and to find which notifications were dismissed without opening the survey) + */ + public String id; + + /** Date/time when the trip survey notification was sent. */ + public Date timeSent; + + /** The {@link TrackedJourney} (and, indirectly, the {@link MonitoredTrip}) that this notification refers to. */ + public String journeyId; + + public TripSurveyNotification() { + // Default constructor for deserialization + } + + public TripSurveyNotification(String id, Date timeSent, String journeyId) { + this.id = id; + this.timeSent = timeSent; + this.journeyId = journeyId; + } +} diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/MonitorAllTripsJob.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/MonitorAllTripsJob.java index 636c904b7..07f8af4af 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/MonitorAllTripsJob.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/MonitorAllTripsJob.java @@ -12,6 +12,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import static org.opentripplanner.middleware.controllers.api.ApiController.ID_FIELD_NAME; + /** * This job will analyze applicable monitored trips and create further individual tasks to analyze each individual trip. */ @@ -55,7 +57,7 @@ public void run() { // This saves bandwidth and memory, as only the ID field is used to set up this job. // The full data for each trip will be fetched at the time the actual analysis takes place. List allTripIds = Persistence.monitoredTrips.getDistinctFieldValues( - "_id", + ID_FIELD_NAME, makeTripFilter(), String.class ).into(new ArrayList<>()); diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java b/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java index f7259396d..f5eee316f 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java @@ -155,6 +155,12 @@ private static EndTrackingResponse completeJourney(TripTrackingData tripData, bo trackedJourney.end(isForciblyEnded); Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_TIME_FIELD_NAME, trackedJourney.endTime); Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_CONDITION_FIELD_NAME, trackedJourney.endCondition); + trackedJourney.longestConsecutiveDeviatedPoints = trackedJourney.computeLargestConsecutiveDeviations(); + Persistence.trackedJourneys.updateField( + trackedJourney.id, + TrackedJourney.LONGEST_CONSECUTIVE_DEVIATED_POINTS_FIELD_NAME, + trackedJourney.longestConsecutiveDeviatedPoints + ); return new EndTrackingResponse( TripInstruction.NO_INSTRUCTION, diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJob.java b/src/main/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJob.java new file mode 100644 index 000000000..f82d65081 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJob.java @@ -0,0 +1,165 @@ +package org.opentripplanner.middleware.triptracker; + +import com.mongodb.client.model.Filters; +import org.bson.conversions.Bson; +import org.opentripplanner.middleware.models.MonitoredTrip; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.TrackedJourney; +import org.opentripplanner.middleware.models.TripSurveyNotification; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.utils.NotificationUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.opentripplanner.middleware.controllers.api.ApiController.ID_FIELD_NAME; +import static org.opentripplanner.middleware.models.MonitoredTrip.USER_ID_FIELD_NAME; +import static org.opentripplanner.middleware.models.OtpUser.TRIP_SURVEY_NOTIFICATIONS_FIELD; +import static org.opentripplanner.middleware.models.TrackedJourney.END_CONDITION_FIELD_NAME; +import static org.opentripplanner.middleware.models.TrackedJourney.END_TIME_FIELD_NAME; +import static org.opentripplanner.middleware.models.TrackedJourney.FORCIBLY_TERMINATED; +import static org.opentripplanner.middleware.models.TrackedJourney.TERMINATED_BY_USER; +import static org.opentripplanner.middleware.models.TripSurveyNotification.TIME_SENT_FIELD; +import static org.opentripplanner.middleware.triptracker.ManageTripTracking.TRIP_TRACKING_UPDATE_FREQUENCY_SECONDS; + +/** + * This job will analyze completed trips with deviations and send survey notifications about select trips. + */ +public class TripSurveySenderJob implements Runnable { + private static final Logger LOG = LoggerFactory.getLogger(TripSurveySenderJob.class); + + @Override + public void run() { + long start = System.currentTimeMillis(); + LOG.info("TripSurveySenderJob started"); + + // Pick users for which the last survey notification was sent more than a week ago. + List usersWithNotificationsOverAWeekAgo = getUsersWithNotificationsOverAWeekAgo(); + + // Collect journeys that were completed/terminated in the past hour (skip ongoing journeys). + List journeysCompletedInPastHour = getCompletedJourneysInPastHour(); + + // Map users to journeys. + Map> usersToJourneys = mapJourneysToUsers(journeysCompletedInPastHour, usersWithNotificationsOverAWeekAgo); + + for (Map.Entry> entry : usersToJourneys.entrySet()) { + // Find journey with the largest total deviation. + Optional optJourney = selectMostDeviatedJourneyUsingDeviatedPoints(entry.getValue()); + if (optJourney.isPresent()) { + // Send push notification about that journey. + MonitoredTrip trip = optJourney.get().trip; + LOG.info("Sending survey notification for trip {}", trip.id); + OtpUser otpUser = entry.getKey(); + String notificationId = UUID.randomUUID().toString(); + String pushResult = NotificationUtils.sendTripSurveyPush(otpUser, trip, notificationId); + if (pushResult != null) { + // Store time of last sent survey notification for user. + otpUser.tripSurveyNotifications.add(new TripSurveyNotification(notificationId, new Date(), optJourney.get().id)); + Persistence.otpUsers.updateField(otpUser.id, TRIP_SURVEY_NOTIFICATIONS_FIELD, otpUser.tripSurveyNotifications); + } else { + LOG.warn("Could not send survey notification for trip {}", trip.id); + } + + } + } + + LOG.info("TripSurveySenderJob completed in {} sec", (System.currentTimeMillis() - start) / 1000); + } + + /** + * Get users whose last trip survey notification was at least a week ago. + */ + public static List getUsersWithNotificationsOverAWeekAgo() { + Date aWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); + + // If TRIP_SURVEY_NOTIFICATIONS_FIELD is not empty, users notified a week ago would have: + // - at least one entry made a week ago, and + // - zero entries made less than a week ago. + Bson dateFilter = Filters.and( + Filters.elemMatch(TRIP_SURVEY_NOTIFICATIONS_FIELD, Filters.lte(TIME_SENT_FIELD, aWeekAgo)), + Filters.not(Filters.elemMatch(TRIP_SURVEY_NOTIFICATIONS_FIELD, Filters.gt(TIME_SENT_FIELD, aWeekAgo))) + ); + + Bson surveyNotSentFilter = Filters.or( + Filters.not(Filters.exists(TRIP_SURVEY_NOTIFICATIONS_FIELD)), + Filters.size(TRIP_SURVEY_NOTIFICATIONS_FIELD, 0) + ); + Bson overallFilter = Filters.or(dateFilter, surveyNotSentFilter); + + return Persistence.otpUsers.getFiltered(overallFilter).into(new ArrayList<>()); + } + + /** + * Gets tracked journeys for all users that were completed in the past hour. + */ + public static List getCompletedJourneysInPastHour() { + Date now = new Date(); + Date oneHourAgo = Date.from(Instant.now().minus(1, ChronoUnit.HOURS)); + Bson dateFilter = Filters.and( + Filters.gte(END_TIME_FIELD_NAME, oneHourAgo), + Filters.lte(END_TIME_FIELD_NAME, now) + ); + Bson completeFilter = Filters.eq(END_CONDITION_FIELD_NAME, TERMINATED_BY_USER); + Bson terminatedFilter = Filters.eq(END_CONDITION_FIELD_NAME, FORCIBLY_TERMINATED); + Bson overallFilter = Filters.and(dateFilter, Filters.or(completeFilter, terminatedFilter)); + + return Persistence.trackedJourneys.getFiltered(overallFilter).into(new ArrayList<>()); + } + + /** + * Gets the trips for the given journeys and users. + */ + public static List getTripsForJourneysAndUsers(List journeys, List otpUsers) { + Set tripIds = journeys.stream().map(j -> j.tripId).collect(Collectors.toSet()); + Set userIds = otpUsers.stream().map(u -> u.id).collect(Collectors.toSet()); + + Bson tripIdFilter = Filters.in(ID_FIELD_NAME, tripIds); + Bson userIdFilter = Filters.in(USER_ID_FIELD_NAME, userIds); + Bson overallFilter = Filters.and(tripIdFilter, userIdFilter); + + return Persistence.monitoredTrips.getFiltered(overallFilter).into(new ArrayList<>()); + } + + /** + * Map journeys to users. + */ + public static Map> mapJourneysToUsers(List journeys, List otpUsers) { + List trips = getTripsForJourneysAndUsers(journeys, otpUsers); + + Map userMap = otpUsers.stream().collect(Collectors.toMap(u -> u.id, Function.identity())); + + HashMap> map = new HashMap<>(); + for (MonitoredTrip trip : trips) { + List journeyList = map.computeIfAbsent(userMap.get(trip.userId), u -> new ArrayList<>()); + for (TrackedJourney journey : journeys) { + if (trip.id.equals(journey.tripId)) { + journey.trip = trip; + journeyList.add(journey); + } + } + } + + return map; + } + + public static Optional selectMostDeviatedJourneyUsingDeviatedPoints(List journeys) { + if (journeys == null) return Optional.empty(); + final double INTERVALS_IN_ONE_MINUTE = Math.ceil(60.0 / TRIP_TRACKING_UPDATE_FREQUENCY_SECONDS); + return journeys.stream() + .filter(j -> j.longestConsecutiveDeviatedPoints >= INTERVALS_IN_ONE_MINUTE) + .max(Comparator.comparingInt(j -> j.longestConsecutiveDeviatedPoints)); + } +} diff --git a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java index e09bf18fc..df51d635e 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java @@ -10,11 +10,13 @@ import com.twilio.rest.verify.v2.service.VerificationCreator; import com.twilio.type.PhoneNumber; import freemarker.template.TemplateException; +import org.apache.logging.log4j.util.Strings; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.util.StringUtil; import org.opentripplanner.middleware.bugsnag.BugsnagReporter; import org.opentripplanner.middleware.models.AdminUser; import org.opentripplanner.middleware.models.Device; +import org.opentripplanner.middleware.models.MonitoredTrip; import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.persistence.Persistence; import org.slf4j.Logger; @@ -24,11 +26,13 @@ import java.net.URI; import java.net.URLEncoder; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.opentripplanner.middleware.i18n.Message.TRIP_SURVEY_NOTIFICATION; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText; /** @@ -50,6 +54,8 @@ public class NotificationUtils { 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"); + private static final String TRIP_SURVEY_ID = getConfigPropertyAsText("TRIP_SURVEY_ID"); + private static final String TRIP_SURVEY_SUBDOMAIN = getConfigPropertyAsText("TRIP_SURVEY_SUBDOMAIN"); /** * Although SMS are 160 characters long and Twilio supports sending up to 1600 characters, @@ -77,8 +83,7 @@ public static String sendPush(OtpUser otpUser, String textTemplate, Object templ 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, tripName, tripId) : "OK"; + return otpUser.pushDevices > 0 ? sendPush(otpUser, body, tripName, tripId, null, null, null) : "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. @@ -86,22 +91,61 @@ public static String sendPush(OtpUser otpUser, String textTemplate, Object templ } } + /** + * @param otpUser target user + * @param trip Trip about which the survey notification is about. + * @param notificationId Notification ID + */ + public static String sendTripSurveyPush(OtpUser otpUser, MonitoredTrip trip, String notificationId) { + // Check devices first - No devices returns OK (favors E2E testing) + if (otpUser.pushDevices == 0) return "OK"; + + // If Push API/survey config properties aren't set, do nothing (will trigger warning log). + if ( + Strings.isBlank(PUSH_API_KEY) || + Strings.isBlank(PUSH_API_URL) || + Strings.isBlank(TRIP_SURVEY_ID) || + Strings.isBlank(TRIP_SURVEY_SUBDOMAIN) + ) { + return null; + } + + Locale locale = I18nUtils.getOtpUserLocale(otpUser); + String tripTime = DateTimeUtils.formatShortDate(trip.itinerary.startTime, locale); + String body = String.format(TRIP_SURVEY_NOTIFICATION.get(locale), tripTime); + return sendPush(otpUser, body, trip.tripName, trip.id, TRIP_SURVEY_ID, TRIP_SURVEY_SUBDOMAIN, notificationId); + } + /** * Send a push notification message to the provided user * @param toUser user account ID (email address) * @param body message body + * @param tripName Monitored trip name to show in notification title * @param tripId Monitored trip ID + * @param surveyId Survey ID + * @param notificationId Notification ID * @return "OK" if message was successful (null otherwise) */ - static String sendPush(String toUser, String body, String tripName, String tripId) { + static String sendPush( + OtpUser toUser, + String body, + String tripName, + String tripId, + String surveyId, + String surveySubdomain, + String notificationId + ) { try { - NotificationInfo notifInfo = new NotificationInfo( + NotificationInfo notificationInfo = new NotificationInfo( + notificationId, toUser, body, tripName, - tripId + tripId, + surveyId, + surveySubdomain ); - var jsonBody = new Gson().toJson(notifInfo); + var jsonBody = new Gson().toJson(notificationInfo); var httpResponse = HttpUtils.httpRequestRawResponse( URI.create(PUSH_API_URL + "/notification/publish?api_key=" + PUSH_API_KEY), 1000, @@ -394,19 +438,54 @@ public static void updatePushDevices(OtpUser otpUser) { } static class NotificationInfo { + /** ID for tracking notifications and survey responses. */ + public final String notificationId; + + /** In reality, the email of the desired user (the push service we use looks up users by email) */ public final String user; + + /** The Mongo ID of the desired user */ + public final String userId; + + /** The message shown in the notification body */ public final String message; + + /** The title of this notification */ public final String title; + + /** The ID of the trip associated to this notification */ public final String tripId; - public NotificationInfo(String user, String message, String title, String tripId) { + /** The ID of the survey to be launched for said trip, if applicable. */ + public final String surveyId; + + /** The subdomain of the website where the survey is administered, if applicable. */ + public final String surveySubdomain; + + public NotificationInfo(String notificationId, OtpUser user, String message, String title, String tripId) { + this(notificationId, user, message, title, tripId, null, null); + } + + public NotificationInfo( + String notificationId, + OtpUser user, + String message, + String title, + String tripId, + String surveyId, + String surveySubdomain + ) { String truncatedTitle = StringUtil.truncate(title, PUSH_TITLE_MAX_LENGTH); int truncatedMessageLength = PUSH_TOTAL_MAX_LENGTH - truncatedTitle.length(); - this.user = user; + this.notificationId = notificationId; + this.user = user.email; + this.userId = user.id; this.title = truncatedTitle; this.message = StringUtil.truncate(message, truncatedMessageLength); this.tripId = tripId; + this.surveyId = surveyId; + this.surveySubdomain = surveySubdomain; } } } diff --git a/src/main/resources/Message.properties b/src/main/resources/Message.properties index aae86669b..d0b090e68 100644 --- a/src/main/resources/Message.properties +++ b/src/main/resources/Message.properties @@ -31,4 +31,5 @@ TRIP_DELAY_LATE = %s late TRIP_DELAY_MINUTES = %d minutes TRIP_NOT_FOUND_NOTIFICATION = Your itinerary was not found in today's trip planner results. Please check real-time conditions and plan a new trip. TRIP_NO_LONGER_POSSIBLE_NOTIFICATION = Your itinerary is no longer possible on any monitored day of the week. Please plan and save a new trip. -TRIP_REMINDER_NOTIFICATION = Reminder for %s at %s. \ No newline at end of file +TRIP_REMINDER_NOTIFICATION = Reminder for %s at %s. +TRIP_SURVEY_NOTIFICATION = How was your most recent trip starting at %s? Tap for a quick survey. \ No newline at end of file diff --git a/src/main/resources/Message_fr.properties b/src/main/resources/Message_fr.properties index 9011607a2..cc147ec24 100644 --- a/src/main/resources/Message_fr.properties +++ b/src/main/resources/Message_fr.properties @@ -31,4 +31,5 @@ TRIP_DELAY_LATE = %s en retard TRIP_DELAY_MINUTES = %d minutes TRIP_NOT_FOUND_NOTIFICATION = Votre trajet est introuvable aujourd'hui. Veuillez vérifier les conditions en temps-réel et recherchez un nouveau trajet. TRIP_NO_LONGER_POSSIBLE_NOTIFICATION = Votre trajet n'est plus possible dans aucun jour de suivi de la semaine. Veuillez rechercher et enregistrer un nouveau trajet. -TRIP_REMINDER_NOTIFICATION = Rappel pour %s à %s. \ No newline at end of file +TRIP_REMINDER_NOTIFICATION = Rappel pour %s à %s. +TRIP_SURVEY_NOTIFICATION = Comment s'est passé votre trajet de %s ? Tapez pour un sondage rapide. \ No newline at end of file diff --git a/src/main/resources/env.schema.json b/src/main/resources/env.schema.json index 2c6828073..f40de5414 100644 --- a/src/main/resources/env.schema.json +++ b/src/main/resources/env.schema.json @@ -319,6 +319,16 @@ "examples": ["10"], "description": "The radius in meters under which an upcoming instruction is given." }, + "TRIP_SURVEY_ID": { + "type": "string", + "examples": ["abcdef123y"], + "description": "The ID of a survey (on the platform of your choice) for trip-related feedback." + }, + "TRIP_SURVEY_SUBDOMAIN": { + "type": "string", + "examples": ["abcabc12a"], + "description": "The subdomain of a website where the trip-related surveys are administered." + }, "TWILIO_ACCOUNT_SID": { "type": "string", "examples": ["your-account-sid"], diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 7a9098fb2..ab497fc4b 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -56,10 +56,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -90,10 +90,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -123,10 +123,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/Job" responseSchema: $ref: "#/definitions/Job" + schema: + $ref: "#/definitions/Job" /api/admin/user: get: tags: @@ -155,10 +155,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/admin/user" @@ -178,10 +178,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -220,10 +220,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -269,10 +269,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -309,10 +309,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/AdminUser" responseSchema: $ref: "#/definitions/AdminUser" + schema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -356,10 +356,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -402,10 +402,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -447,10 +447,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/TokenHolder" responseSchema: $ref: "#/definitions/TokenHolder" + schema: + $ref: "#/definitions/TokenHolder" /api/secure/application/fromtoken: get: tags: @@ -464,10 +464,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -498,10 +498,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -531,10 +531,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/Job" responseSchema: $ref: "#/definitions/Job" + schema: + $ref: "#/definitions/Job" /api/secure/application: get: tags: @@ -563,10 +563,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/application" @@ -586,10 +586,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -628,10 +628,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -677,10 +677,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -717,10 +717,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ApiUser" responseSchema: $ref: "#/definitions/ApiUser" + schema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -754,10 +754,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -788,10 +788,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -821,10 +821,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/Job" responseSchema: $ref: "#/definitions/Job" + schema: + $ref: "#/definitions/Job" /api/secure/cdp: get: tags: @@ -853,10 +853,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/cdp" @@ -876,10 +876,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -918,10 +918,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -967,10 +967,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1007,10 +1007,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/CDPUser" responseSchema: $ref: "#/definitions/CDPUser" + schema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1050,10 +1050,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/ItineraryExistence" responseSchema: $ref: "#/definitions/ItineraryExistence" + schema: + $ref: "#/definitions/ItineraryExistence" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1102,10 +1102,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/monitoredtrip" @@ -1125,10 +1125,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredTrip" responseSchema: $ref: "#/definitions/MonitoredTrip" + schema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1167,10 +1167,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredTrip" responseSchema: $ref: "#/definitions/MonitoredTrip" + schema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1216,10 +1216,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredTrip" responseSchema: $ref: "#/definitions/MonitoredTrip" + schema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1257,10 +1257,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredTrip" responseSchema: $ref: "#/definitions/MonitoredTrip" + schema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1298,10 +1298,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/TrackingResponse" responseSchema: $ref: "#/definitions/TrackingResponse" + schema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/updatetracking: post: tags: @@ -1319,10 +1319,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/TrackingResponse" responseSchema: $ref: "#/definitions/TrackingResponse" + schema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/track: post: tags: @@ -1340,10 +1340,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/TrackingResponse" responseSchema: $ref: "#/definitions/TrackingResponse" + schema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/endtracking: post: tags: @@ -1361,10 +1361,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/EndTrackingResponse" responseSchema: $ref: "#/definitions/EndTrackingResponse" + schema: + $ref: "#/definitions/EndTrackingResponse" /api/secure/monitoredtrip/forciblyendtracking: post: tags: @@ -1382,10 +1382,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/EndTrackingResponse" responseSchema: $ref: "#/definitions/EndTrackingResponse" + schema: + $ref: "#/definitions/EndTrackingResponse" /api/secure/triprequests: get: tags: @@ -1430,10 +1430,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/TripRequest" responseSchema: $ref: "#/definitions/TripRequest" + schema: + $ref: "#/definitions/TripRequest" /api/secure/monitoredcomponent: get: tags: @@ -1462,10 +1462,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/monitoredcomponent" @@ -1485,10 +1485,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredComponent" responseSchema: $ref: "#/definitions/MonitoredComponent" + schema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1527,10 +1527,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredComponent" responseSchema: $ref: "#/definitions/MonitoredComponent" + schema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1576,10 +1576,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredComponent" responseSchema: $ref: "#/definitions/MonitoredComponent" + schema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1617,10 +1617,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MonitoredComponent" responseSchema: $ref: "#/definitions/MonitoredComponent" + schema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1651,10 +1651,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1694,10 +1694,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/VerificationResult" responseSchema: $ref: "#/definitions/VerificationResult" + schema: + $ref: "#/definitions/VerificationResult" /api/secure/user/{id}/verify_sms/{code}: post: tags: @@ -1717,10 +1717,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/VerificationResult" responseSchema: $ref: "#/definitions/VerificationResult" + schema: + $ref: "#/definitions/VerificationResult" /api/secure/user/fromtoken: get: tags: @@ -1734,10 +1734,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1768,10 +1768,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1801,10 +1801,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/Job" responseSchema: $ref: "#/definitions/Job" + schema: + $ref: "#/definitions/Job" /api/secure/user: get: tags: @@ -1833,10 +1833,10 @@ paths: responses: "200": description: "successful operation" - schema: - $ref: "#/definitions/ResponseList" responseSchema: $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/user" @@ -1856,10 +1856,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1898,10 +1898,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1947,10 +1947,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1987,10 +1987,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/OtpUser" responseSchema: $ref: "#/definitions/OtpUser" + schema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -2044,11 +2044,11 @@ paths: responses: "200": description: "successful operation" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/ApiUsageResult" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/ApiUsageResult" @@ -2075,11 +2075,11 @@ paths: responses: "200": description: "successful operation" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/BugsnagEvent" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/BugsnagEvent" @@ -2106,11 +2106,11 @@ paths: responses: "200": description: "successful operation" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/CDPFile" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/CDPFile" @@ -2131,11 +2131,11 @@ paths: responses: "200": description: "successful operation" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/URL" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/URL" @@ -3173,6 +3173,10 @@ definitions: $ref: "#/definitions/UserLocation" storeTripHistory: type: "boolean" + tripSurveyNotifications: + type: "array" + items: + $ref: "#/definitions/TripSurveyNotification" applicationId: type: "string" relatedUsers: @@ -3202,6 +3206,16 @@ definitions: - "LEGALLY_BLIND" - "LOW_VISION" - "NONE" + TripSurveyNotification: + type: "object" + properties: + id: + type: "string" + timeSent: + type: "string" + format: "date" + journeyId: + type: "string" GetUsageResult: type: "object" properties: diff --git a/src/main/resources/templates/MonitoredTripPush.ftl b/src/main/resources/templates/MonitoredTripPush.ftl index 68ce7b842..35ef5c6bc 100644 --- a/src/main/resources/templates/MonitoredTripPush.ftl +++ b/src/main/resources/templates/MonitoredTripPush.ftl @@ -3,7 +3,7 @@ OTP user's monitored trip. Note the following character limitations by mobile OS: - iOS: 178 characters over up to 4 lines, - - Android: 240 characters (We are not using notification title at this time). + - Android: 240 characters (excluding notification title). The max length is thus 178 characters. - List alerts with bullets if there are more than one of them. --> diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java index 0ec06df67..25e05d315 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java @@ -58,6 +58,8 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.opentripplanner.middleware.auth.Auth0Connection.restoreDefaultAuthDisabled; import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; +import static org.opentripplanner.middleware.models.TrackedJourney.FORCIBLY_TERMINATED; +import static org.opentripplanner.middleware.models.TrackedJourney.TERMINATED_BY_USER; import static org.opentripplanner.middleware.testutils.ApiTestUtils.TEMP_AUTH0_USER_PASSWORD; import static org.opentripplanner.middleware.testutils.ApiTestUtils.getMockHeaders; import static org.opentripplanner.middleware.testutils.ApiTestUtils.makeRequest; @@ -192,6 +194,10 @@ void canCompleteJourneyLifeCycle() throws Exception { assertEquals(TripStatus.ENDED.name(), endTrackingResponse.tripStatus); assertEquals(HttpStatus.OK_200, response.status); + // Check that the TrackedJourney Mongo record has been updated. + TrackedJourney mongoTrackedJourney = Persistence.trackedJourneys.getById(startTrackingResponse.journeyId); + assertEquals(TERMINATED_BY_USER, mongoTrackedJourney.endCondition); + assertNotEquals(-1, mongoTrackedJourney.longestConsecutiveDeviatedPoints); DateTimeUtils.useSystemDefaultClockAndTimezone(); } @@ -422,6 +428,11 @@ void canForciblyEndJourney() throws Exception { var endTrackingResponse = JsonUtils.getPOJOFromJSON(response.responseBody, EndTrackingResponse.class); assertEquals(TripStatus.ENDED.name(), endTrackingResponse.tripStatus); assertEquals(HttpStatus.OK_200, response.status); + + // Check that the TrackedJourney Mongo record has been updated. + TrackedJourney mongoTrackedJourney = Persistence.trackedJourneys.getById(startTrackingResponse.journeyId); + assertEquals(FORCIBLY_TERMINATED, mongoTrackedJourney.endCondition); + assertNotEquals(-1, mongoTrackedJourney.longestConsecutiveDeviatedPoints); } @Test diff --git a/src/test/java/org/opentripplanner/middleware/models/TrackedJourneyTest.java b/src/test/java/org/opentripplanner/middleware/models/TrackedJourneyTest.java new file mode 100644 index 000000000..a5fbc417c --- /dev/null +++ b/src/test/java/org/opentripplanner/middleware/models/TrackedJourneyTest.java @@ -0,0 +1,39 @@ +package org.opentripplanner.middleware.models; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.middleware.triptracker.TrackingLocation; +import org.opentripplanner.middleware.triptracker.TripStatus; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TrackedJourneyTest { + @Test + void canComputeLargestConsecutiveDeviations() { + TrackedJourney journey = new TrackedJourney(); + journey.locations = null; + assertEquals(-1, journey.computeLargestConsecutiveDeviations()); + + journey.locations = Stream + .of(0, 1, 1, 1, null, 1, 1, 0, 0, null, 1, 1, 1, null, 0) + .map(d -> { + TrackingLocation location = new TrackingLocation(); + location.tripStatus = d == null + ? null + : d == 1 + ? TripStatus.DEVIATED + : TripStatus.ON_SCHEDULE; + location.speed = 1; + return location; + }) + .collect(Collectors.toList()); + + // Insert a location where the traveler is not moving. + journey.locations.get(5).speed = 0; + + // After excluding the nulls, count the first group of consecutive ones. + assertEquals(4, journey.computeLargestConsecutiveDeviations()); + } +} diff --git a/src/test/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJobTest.java b/src/test/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJobTest.java new file mode 100644 index 000000000..382efe4e1 --- /dev/null +++ b/src/test/java/org/opentripplanner/middleware/triptracker/TripSurveySenderJobTest.java @@ -0,0 +1,246 @@ +package org.opentripplanner.middleware.triptracker; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.opentripplanner.middleware.models.MonitoredTrip; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.TrackedJourney; +import org.opentripplanner.middleware.models.TripSurveyNotification; +import org.opentripplanner.middleware.otp.response.Itinerary; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.testutils.ApiTestUtils; +import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; +import org.opentripplanner.middleware.testutils.PersistenceTestUtils; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.opentripplanner.middleware.models.OtpUser.TRIP_SURVEY_NOTIFICATIONS_FIELD; +import static org.opentripplanner.middleware.models.TrackedJourney.FORCIBLY_TERMINATED; +import static org.opentripplanner.middleware.models.TrackedJourney.TERMINATED_BY_USER; + +class TripSurveySenderJobTest extends OtpMiddlewareTestEnvironment { + + private static OtpUser user1notifiedNow; + private static OtpUser user2notifiedAWeekAgo; + private static OtpUser user3neverNotified; + + private static List otpUsers = List.of(); + private static List journeys = List.of(); + private static MonitoredTrip trip; + private static final Date EIGHT_DAYS_AGO = Date.from(Instant.now().minus(8, ChronoUnit.DAYS)); + private static final TripSurveyNotification SURVEY_NOTIFICATION_EIGHT_DAYS_AGO = new TripSurveyNotification( + "notification-8-days-ago", + EIGHT_DAYS_AGO, + "journey-2" + ); + + @BeforeAll + public static void setUp() { + assumeTrue(IS_END_TO_END); + + // Create users and populate the date for last trip survey notification. + user1notifiedNow = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("test-user1")); + user2notifiedAWeekAgo = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("test-user2")); + user3neverNotified = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("test-user3")); + + user1notifiedNow.tripSurveyNotifications.add( + new TripSurveyNotification("notification-id-1", new Date(), "journey-1") + ); + user1notifiedNow.tripSurveyNotifications.add(SURVEY_NOTIFICATION_EIGHT_DAYS_AGO); + user2notifiedAWeekAgo.tripSurveyNotifications.add(SURVEY_NOTIFICATION_EIGHT_DAYS_AGO); + user2notifiedAWeekAgo.tripSurveyNotifications.add( + new TripSurveyNotification("notification-id-2", Date.from(Instant.EPOCH), "journey-1") + ); + + otpUsers = List.of(user1notifiedNow, user2notifiedAWeekAgo, user3neverNotified); + otpUsers.forEach(user -> Persistence.otpUsers.replace(user.id, user)); + + // Use one user for all trips and journeys (trips will be deleted with OtpUser.delete() on tear down. + trip = new MonitoredTrip(); + trip.id = String.format("%s-trip-id", user1notifiedNow.id); + trip.userId = user2notifiedAWeekAgo.id; + trip.itinerary = new Itinerary(); + trip.itinerary.startTime = new Date(); + Persistence.monitoredTrips.create(trip); + } + + @AfterAll + public static void tearDown() { + assumeTrue(IS_END_TO_END); + + // Delete users + otpUsers.forEach(user -> { + OtpUser storedUser = Persistence.otpUsers.getById(user.id); + if (storedUser != null) storedUser.delete(false); + }); + } + + @AfterEach + void afterEach() { + assumeTrue(IS_END_TO_END); + + // Delete journeys + for (TrackedJourney journey : journeys) { + TrackedJourney storedJourney = Persistence.trackedJourneys.getById(journey.id); + if (storedJourney != null) storedJourney.delete(); + } + + // Reset notification sent time to affected user. + Persistence.otpUsers.updateField( + user2notifiedAWeekAgo.id, + TRIP_SURVEY_NOTIFICATIONS_FIELD, + List.of(SURVEY_NOTIFICATION_EIGHT_DAYS_AGO) + ); + } + + @Test + void canGetUsersWithNotificationsOverAWeekAgo() { + assumeTrue(IS_END_TO_END); + + List usersWithNotificationsOverAWeekAgo = TripSurveySenderJob.getUsersWithNotificationsOverAWeekAgo(); + assertEquals(2, usersWithNotificationsOverAWeekAgo.size()); + List expectedUserIds = List.of(user2notifiedAWeekAgo.id, user3neverNotified.id); + assertTrue(expectedUserIds.contains(usersWithNotificationsOverAWeekAgo.get(0).id)); + assertTrue(expectedUserIds.contains(usersWithNotificationsOverAWeekAgo.get(1).id)); + } + + @Test + void canGetCompletedJourneysInPastHour() { + assumeTrue(IS_END_TO_END); + createTestJourneys(); + + List completedJourneys = TripSurveySenderJob.getCompletedJourneysInPastHour(); + assertEquals(2, completedJourneys.size()); + } + + private static TrackedJourney createJourney( + String id, + String tripId, + Instant endTime, + String endCondition, + int points + ) { + TrackedJourney journey = new TrackedJourney(); + journey.id = id; + journey.tripId = tripId; + journey.endCondition = endCondition; + journey.endTime = endTime != null ? Date.from(endTime) : null; + journey.startTime = journey.endTime; + journey.longestConsecutiveDeviatedPoints = points; + Persistence.trackedJourneys.create(journey); + return journey; + } + + @Test + void canMapJourneysToUsers() { + assumeTrue(IS_END_TO_END); + createTestJourneys(); + + List trips = TripSurveySenderJob.getTripsForJourneysAndUsers(journeys, otpUsers); + assertEquals(1, trips.size()); + assertEquals(trip.id, trips.get(0).id); + + Map> usersToJourneys = TripSurveySenderJob.mapJourneysToUsers(journeys, otpUsers); + assertEquals(1, usersToJourneys.size()); + List userJourneys = usersToJourneys.get(otpUsers.get(1)); + assertEquals(5, userJourneys.size()); + assertTrue(userJourneys.containsAll(journeys.subList(0, 4))); + for (TrackedJourney journey : userJourneys) { + assertEquals(trip, journey.trip); + } + } + + @Test + void canSelectMostDeviatedJourneyUsingDeviatedPoints() { + TrackedJourney journey1 = new TrackedJourney(); + journey1.longestConsecutiveDeviatedPoints = 250; + journey1.endTime = Date.from(Instant.now().minus(3, ChronoUnit.HOURS)); + + TrackedJourney journey2 = new TrackedJourney(); + journey2.longestConsecutiveDeviatedPoints = 400; + journey2.endTime = Date.from(Instant.now().minus(5, ChronoUnit.HOURS)); + + Optional optJourney = TripSurveySenderJob.selectMostDeviatedJourneyUsingDeviatedPoints(List.of(journey1, journey2)); + assertTrue(optJourney.isPresent()); + assertEquals(journey2, optJourney.get()); + } + + @Test + void canSelectDeviatedJourneyWithDeviatedPointsThreshold() { + TrackedJourney journey1 = new TrackedJourney(); + journey1.longestConsecutiveDeviatedPoints = 6; + journey1.endTime = Date.from(Instant.now().minus(3, ChronoUnit.HOURS)); + + TrackedJourney journey2 = new TrackedJourney(); + journey2.longestConsecutiveDeviatedPoints = 8; + journey2.endTime = Date.from(Instant.now().minus(5, ChronoUnit.HOURS)); + + Optional optJourney = TripSurveySenderJob.selectMostDeviatedJourneyUsingDeviatedPoints(List.of(journey1, journey2)); + assertFalse(optJourney.isPresent()); + } + + @Test + void canRunJob() { + assumeTrue(IS_END_TO_END); + createTestJourneys(); + + Date start = new Date(); + OtpUser storedUser = Persistence.otpUsers.getById(user2notifiedAWeekAgo.id); + assertFalse(start.before(storedUser.findLastTripSurveyNotificationSent().get().timeSent)); + + TripSurveySenderJob job = new TripSurveySenderJob(); + job.run(); + + storedUser = Persistence.otpUsers.getById(user2notifiedAWeekAgo.id); + assertTrue(start.before(storedUser.findLastTripSurveyNotificationSent().get().timeSent)); + + // Other user last notification should not have changed. + storedUser = Persistence.otpUsers.getById(user1notifiedNow.id); + Optional notification = storedUser.findLastTripSurveyNotificationSent(); + assertFalse(start.before(notification.get().timeSent)); + assertNotNull(notification.get().id); + storedUser = Persistence.otpUsers.getById(user3neverNotified.id); + assertTrue(storedUser.findLastTripSurveyNotificationSent().isEmpty()); + } + + private static void createTestJourneys() { + Instant threeMinutesInFuture = Instant.now().plus(3, ChronoUnit.MINUTES); + Instant thirtyMinutesAgo = Instant.now().minus(30, ChronoUnit.MINUTES); + Instant threeDaysAgo = Instant.now().minus(3, ChronoUnit.DAYS); + + // Create journey for each trip for all users above (they will be deleted explicitly after each test). + journeys = List.of( + // Ongoing journey (should not be included) + createJourney("ongoing-journey", trip.id, null, null, 10), + + // Journey completed by user 30 minutes ago (should be included) + createJourney("user-terminated-journey", trip.id, thirtyMinutesAgo, TERMINATED_BY_USER, 200), + + // Journey terminated forcibly 30 minutes ago (should be included) + createJourney("forcibly-terminated-journey", trip.id, thirtyMinutesAgo, FORCIBLY_TERMINATED, 400), + + // Additional journey completed over an hour ago (should not be included). + createJourney("journey-done-3-days-ago", trip.id, threeDaysAgo, TERMINATED_BY_USER, 10), + + // Additional journey completed with bogus time in future (should not be included). + createJourney("journey-done-3-hours-ago", trip.id, threeMinutesInFuture, TERMINATED_BY_USER, 10), + + // Orphan journeys (should not be included) + createJourney("journey-3", "other-trip", null, null, 10), + createJourney("journey-4", "other-trip", null, null, 0) + ); + } +} + diff --git a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java index ec3a48b2a..22f6b546e 100644 --- a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java +++ b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import org.opentripplanner.middleware.models.Device; +import org.opentripplanner.middleware.models.OtpUser; import java.util.List; import java.util.stream.Stream; @@ -85,8 +86,13 @@ void testNumberOfUniqueDevices() { @ParameterizedTest @MethodSource("createNotificationInfoCases") void testTruncateNotificationPayload(String originalTitle, String expectedTitle, String originalMessage, String expectedMessage, String message) { + OtpUser user = new OtpUser(); + user.id = "user123u"; + user.email = "user@example.com"; + NotificationUtils.NotificationInfo info = new NotificationUtils.NotificationInfo( - "user@example.com", + "notification-id", + user, originalMessage, originalTitle, "trip-id" diff --git a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTestCI.java b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTestCI.java index 81d9d06d4..00a421980 100644 --- a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTestCI.java +++ b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTestCI.java @@ -12,7 +12,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.util.Date; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -25,7 +24,7 @@ * 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 NotificationUtilsTestCI extends OtpMiddlewareTestEnvironment { +class NotificationUtilsTestCI extends OtpMiddlewareTestEnvironment { private static final Logger LOG = LoggerFactory.getLogger(NotificationUtilsTestCI.class); private static OtpUser user; @@ -36,16 +35,15 @@ public class NotificationUtilsTestCI 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 && push != null; + !isRunningCi && IS_END_TO_END && email != null && phone != null; @BeforeAll - public static void setup() throws IOException { + public static void setup() { assumeTrue(shouldTestsRun); user = createUser(email, phone); } @@ -56,20 +54,20 @@ public static void tearDown() { } @Test - public void canSendPushNotification() { + void canSendPushNotification() { String ret = NotificationUtils.sendPush( - // Conventionally user.email - push, + user, "Tough little ship!", "Titanic", - "trip-id" + "trip-id", + "survey-id" ); - LOG.info("Push notification (ret={}) sent to {}", ret, push); + LOG.info("Push notification (ret={}) sent to {}", ret, user.email); Assertions.assertNotNull(ret); } @Test - public void canSendSparkpostEmailNotification() { + void canSendSparkpostEmailNotification() { boolean success = NotificationUtils.sendEmailViaSparkpost( OTP_ADMIN_DASHBOARD_FROM_EMAIL, user.email, @@ -81,7 +79,7 @@ public void canSendSparkpostEmailNotification() { } @Test - public void canSendSmsNotification() { + void canSendSmsNotification() { // Note: toPhone must be verified. String messageId = NotificationUtils.sendSMS( // Note: phone number is configured in setup method above. @@ -96,7 +94,7 @@ public void canSendSmsNotification() { * Tests whether a verification code can be sent to a phone number. */ @Test - public void canSendTwilioVerificationText() { + void canSendTwilioVerificationText() { Assertions.assertNull(user.smsConsentDate); Date beforeVerificationDate = new Date(); Verification verification = NotificationUtils.sendVerificationText( @@ -117,7 +115,7 @@ public void canSendTwilioVerificationText() { * your phone can be used below (in place of 123456) to generate an "approved" status. */ @Test - public void canCheckSmsVerificationCode() { + void canCheckSmsVerificationCode() { VerificationCheck check = NotificationUtils.checkSmsVerificationCode( // Note: phone number is configured in setup method above. user.phoneNumber,