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 49b854425..6a5877510 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -9,10 +9,12 @@ import org.opentripplanner.middleware.models.MonitoredTrip; import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.models.RelatedUser; import org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip; import org.opentripplanner.middleware.tripmonitor.jobs.MonitoredTripLocks; import org.opentripplanner.middleware.utils.InvalidItineraryReason; import org.opentripplanner.middleware.utils.JsonUtils; +import org.opentripplanner.middleware.utils.NotificationUtils; import org.opentripplanner.middleware.utils.SwaggerUtils; import spark.Request; import spark.Response; @@ -22,12 +24,17 @@ import static io.github.manusant.ss.descriptor.MethodDescriptor.path; import static com.mongodb.client.model.Filters.eq; +import static org.opentripplanner.middleware.i18n.Message.TRIP_INVITE_COMPANION; +import static org.opentripplanner.middleware.i18n.Message.TRIP_INVITE_OBSERVER; +import static org.opentripplanner.middleware.i18n.Message.TRIP_INVITE_PRIMARY_TRAVELER; import static org.opentripplanner.middleware.models.MonitoredTrip.USER_ID_FIELD_NAME; +import static org.opentripplanner.middleware.models.MonitoredTrip.getAddedUsers; 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; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; + /** * Implementation of the {@link ApiController} abstract class for managing {@link MonitoredTrip} entities. This * controller connects with Auth0 services using the hooks provided by {@link ApiController}. @@ -90,6 +97,8 @@ MonitoredTrip preCreateHook(MonitoredTrip monitoredTrip, Request req) { } } + notifyTripCompanionsAndObservers(monitoredTrip, null); + return monitoredTrip; } @@ -128,6 +137,30 @@ private void preCreateOrUpdateChecks(MonitoredTrip monitoredTrip, Request req) { processTripQueryParams(monitoredTrip, req); } + /** Notify users added as companions or observers to a trip. (Removed users won't get notified.) */ + private void notifyTripCompanionsAndObservers(MonitoredTrip monitoredTrip, MonitoredTrip originalTrip) { + MonitoredTrip.TripUsers usersToNotify = getAddedUsers(monitoredTrip, originalTrip); + OtpUser tripCreator = Persistence.otpUsers.getById(monitoredTrip.userId); + + if (usersToNotify.companion != null) { + OtpUser companionUser = Persistence.otpUsers.getOneFiltered(Filters.eq("email", usersToNotify.companion.email)); + NotificationUtils.notifyCompanion(monitoredTrip, tripCreator, companionUser, TRIP_INVITE_COMPANION); + } + + if (usersToNotify.primary != null) { + // email could be used too for primary users + OtpUser primaryUser = Persistence.otpUsers.getById(usersToNotify.primary.userId); + NotificationUtils.notifyCompanion(monitoredTrip, tripCreator, primaryUser, TRIP_INVITE_PRIMARY_TRAVELER); + } + + if (!usersToNotify.observers.isEmpty()) { + for (RelatedUser observer : usersToNotify.observers) { + OtpUser observerUser = Persistence.otpUsers.getOneFiltered(Filters.eq("email", observer.email)); + NotificationUtils.notifyCompanion(monitoredTrip, tripCreator, observerUser, TRIP_INVITE_OBSERVER); + } + } + } + /** * Processes the {@link MonitoredTrip} query parameters, so the trip's fields match the query parameters. * If an error occurs regarding the query params, returns a HTTP 400 status. @@ -171,6 +204,9 @@ MonitoredTrip preUpdateHook(MonitoredTrip monitoredTrip, MonitoredTrip preExisti // perform the database update here before releasing the lock to be sure that the record is updated in the // database before a CheckMonitoredTripJob analyzes the data Persistence.monitoredTrips.replace(monitoredTrip.id, monitoredTrip); + + notifyTripCompanionsAndObservers(monitoredTrip, preExisting); + return runCheckMonitoredTrip(monitoredTrip); } catch (Exception e) { // FIXME: an error happened while updating the trip, but the trip might have been saved to the DB, so return diff --git a/src/main/java/org/opentripplanner/middleware/i18n/Message.java b/src/main/java/org/opentripplanner/middleware/i18n/Message.java index 1870b5dc1..5449885b2 100644 --- a/src/main/java/org/opentripplanner/middleware/i18n/Message.java +++ b/src/main/java/org/opentripplanner/middleware/i18n/Message.java @@ -44,6 +44,9 @@ public enum Message { TRIP_DELAY_EARLY, TRIP_DELAY_LATE, TRIP_DELAY_MINUTES, + TRIP_INVITE_COMPANION, + TRIP_INVITE_PRIMARY_TRAVELER, + TRIP_INVITE_OBSERVER, TRIP_NOT_FOUND_NOTIFICATION, TRIP_NO_LONGER_POSSIBLE_NOTIFICATION, TRIP_REMINDER_NOTIFICATION, diff --git a/src/main/java/org/opentripplanner/middleware/models/AbstractUser.java b/src/main/java/org/opentripplanner/middleware/models/AbstractUser.java index 7164e3491..16ea0ab6f 100644 --- a/src/main/java/org/opentripplanner/middleware/models/AbstractUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/AbstractUser.java @@ -1,6 +1,7 @@ package org.opentripplanner.middleware.models; import com.auth0.exception.Auth0Exception; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.opentripplanner.middleware.auth.Auth0Connection; import org.opentripplanner.middleware.auth.RequestingUser; import org.opentripplanner.middleware.auth.Permission; @@ -19,6 +20,7 @@ * It provides a place to centralize common fields that all users share (e.g., email) and common methods (such as the * authorization check {@link #canBeManagedBy}. */ +@JsonIgnoreProperties(ignoreUnknown = true) public abstract class AbstractUser extends Model { private static final Logger LOG = LoggerFactory.getLogger(AbstractUser.class); private static final long serialVersionUID = 1L; diff --git a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java index b688282c1..4a49703a5 100644 --- a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java @@ -27,7 +27,10 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; /** * A monitored trip represents a trip a user would like to receive notification on if affected by a delay and/or route @@ -432,6 +435,69 @@ public boolean isOneTime() { * @return true if the trip has a (confirmed) companion, false otherwise. */ public boolean hasConfirmedCompanion() { - return companion != null && companion.status == RelatedUser.RelatedUserStatus.CONFIRMED; + return companion != null && companion.isConfirmed(); + } + + /** + * Gets users not previously involved (as primary traveler, companion, or observer) in a trip. + */ + public static TripUsers getAddedUsers(MonitoredTrip monitoredTrip, MonitoredTrip originalTrip) { + RelatedUser addedCompanion = null; + if (monitoredTrip.hasConfirmedCompanion() && ( + originalTrip == null || + originalTrip.companion == null || + !originalTrip.companion.email.equals(monitoredTrip.companion.email) + )) { + // Include the companion if creating trip or setting companion for the first time, + // or setting a different companion. + addedCompanion = monitoredTrip.companion; + } + + MobilityProfileLite addedPrimaryTraveler = null; + if (monitoredTrip.primary != null && ( + originalTrip == null || + originalTrip.primary == null || + !originalTrip.primary.userId.equals(monitoredTrip.primary.userId) + )) { + // Include the primary traveler if creating trip or setting primary traveler for the first time, + // or setting a different primary traveler. + addedPrimaryTraveler = monitoredTrip.primary; + } + + List addedObservers = new ArrayList<>(); + if (monitoredTrip.observers != null) { + List confirmedObservers = monitoredTrip.observers.stream() + .filter(Objects::nonNull) + .filter(RelatedUser::isConfirmed) + .collect(Collectors.toList()); + if (originalTrip == null || originalTrip.observers == null) { + // Include all observers if creating trip or setting observers for the first time. + addedObservers.addAll(confirmedObservers); + } else { + // If observers have been set before, include observers not previously present. + Set existingObserverEmails = originalTrip.observers.stream() + .map(obs -> obs.email) + .collect(Collectors.toSet()); + confirmedObservers.forEach(obs -> { + if (!existingObserverEmails.contains(obs.email)) { + addedObservers.add(obs); + } + }); + } + } + + return new TripUsers(addedPrimaryTraveler, addedCompanion, addedObservers); + } + + public static class TripUsers { + public final RelatedUser companion; + public final List observers; + public final MobilityProfileLite primary; + + public TripUsers(MobilityProfileLite primary, RelatedUser companion, List observers) { + this.primary = primary; + this.companion = companion; + this.observers = observers; + } } } diff --git a/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java index 920e94eda..25b518b56 100644 --- a/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java @@ -1,5 +1,8 @@ package org.opentripplanner.middleware.models; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.bson.codecs.pojo.annotations.BsonIgnore; + /** A related user is a companion or observer requested by a dependent. */ public class RelatedUser { public enum RelatedUserStatus { @@ -15,6 +18,10 @@ public RelatedUser() { // Required for JSON deserialization. } + public RelatedUser(String email, RelatedUserStatus status) { + this(email, status, null); + } + public RelatedUser(String email, RelatedUserStatus status, String nickname) { this.email = email; this.status = status; @@ -25,5 +32,11 @@ public RelatedUser(String email, RelatedUserStatus status, String nickname, Stri this (email, status, nickname); this.acceptKey = acceptKey; } + + @JsonIgnore + @BsonIgnore + public boolean isConfirmed() { + return status == RelatedUserStatus.CONFIRMED; + } } 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 d8c44769f..d8fbb962c 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java @@ -28,6 +28,7 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -59,8 +60,6 @@ public class CheckMonitoredTrip implements Runnable { public static final String ACCOUNT_PATH = "/#/account"; - private final String TRIPS_PATH = ACCOUNT_PATH + "/trips"; - public static final String SETTINGS_PATH = ACCOUNT_PATH + "/settings"; public final MonitoredTrip trip; @@ -529,21 +528,15 @@ private void sendNotifications() { String tripNameOrReminder = hasInitialReminder ? initialReminderNotification.body : trip.tripName; Locale locale = getOtpUserLocale(); - String tripLinkLabel = Message.TRIP_LINK_TEXT.get(locale); - String tripUrl = getTripUrl(); // A HashMap is needed instead of a Map for template data to be serialized to the template renderer. - Map templateData = new HashMap<>(Map.of( + Map templateData = new HashMap<>(); + templateData.putAll(Map.of( "emailGreeting", Message.TRIP_EMAIL_GREETING.get(locale), "tripNameOrReminder", tripNameOrReminder, - "tripLinkLabelAndUrl", label(tripLinkLabel, tripUrl, locale), - "tripLinkAnchorLabel", tripLinkLabel, - "tripUrl", tripUrl, - "emailFooter", String.format(Message.TRIP_EMAIL_FOOTER.get(locale), OTP_UI_NAME), - "manageLinkText", Message.TRIP_EMAIL_MANAGE_NOTIFICATIONS.get(locale), - "manageLinkUrl", String.format("%s%s", OTP_UI_URL, SETTINGS_PATH), "notifications", new ArrayList<>(notifications), "smsFooter", Message.SMS_STOP_NOTIFICATIONS.get(locale) )); + templateData.putAll(NotificationUtils.getTripNotificationFields(trip, locale)); if (hasInitialReminder) { templateData.put("initialReminder", initialReminderNotification); } @@ -588,9 +581,7 @@ private boolean sendPush(OtpUser otpUser, Map data) { */ private boolean sendEmail(OtpUser otpUser, Map data) { Locale locale = getOtpUserLocale(); - String subject = trip.tripName != null - ? String.format(Message.TRIP_EMAIL_SUBJECT.get(locale), trip.tripName) - : String.format(Message.TRIP_EMAIL_SUBJECT_FOR_USER.get(locale), otpUser.email); + String subject = NotificationUtils.getTripEmailSubject(otpUser, locale, trip); return NotificationUtils.sendEmail( otpUser, subject, @@ -670,6 +661,18 @@ public boolean shouldSkipMonitoredTripCheck(boolean persist) throws Exception { return true; } + // For trips that are snoozed, see if they should be unsnoozed first. + if (trip.snoozed) { + if (shouldUnsnoozeTrip()) { + // Clear previous matching itinerary as we want to start afresh. + // The snoozed state will be updated later in the process. + previousMatchingItinerary = null; + } else { + LOG.info("Skipping: Trip is snoozed."); + return true; + } + } + if (isPrevMatchingItineraryNotConcluded()) { // Skip checking the trip the rest of the time that it is active if the trip was deemed not possible for the // next possible time during a previous query to find candidate itinerary matches. @@ -678,12 +681,6 @@ public boolean shouldSkipMonitoredTripCheck(boolean persist) throws Exception { return true; } - // skip checking the trip if it has been snoozed - if (trip.snoozed) { - LOG.info("Skipping: Trip is snoozed."); - return true; - } - matchingItinerary = previousMatchingItinerary; targetZonedDateTime = DateTimeUtils.makeOtpZonedDateTime(previousJourneyState.targetDate, trip.tripTime); } else { @@ -926,7 +923,24 @@ private Locale getOtpUserLocale() { return I18nUtils.getOtpUserLocale(getOtpUser()); } - private String getTripUrl() { - return String.format("%s%s/%s", OTP_UI_URL, TRIPS_PATH, trip.id); + /** + * Whether a trip should be unsnoozed and monitoring should resume. + * @return true if the current time is after the calendar day (on or after midnight) + * after the matching trip start day, false otherwise. + */ + public boolean shouldUnsnoozeTrip() { + ZoneId otpZoneId = DateTimeUtils.getOtpZoneId(); + var midnightAfterLastChecked = ZonedDateTime + .ofInstant( + Instant.ofEpochMilli(previousJourneyState.lastCheckedEpochMillis).plus(1, ChronoUnit.DAYS), + otpZoneId + ) + .withHour(0) + .withMinute(0) + .withSecond(0); + + ZonedDateTime now = DateTimeUtils.nowAsZonedDateTime(otpZoneId); + // Include equal or after midnight as true. + return !now.isBefore(midnightAfterLastChecked); } } diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java index e444fbe62..5c7e5da32 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java @@ -4,6 +4,7 @@ import org.opentripplanner.middleware.otp.response.Leg; import org.opentripplanner.middleware.otp.response.Place; import org.opentripplanner.middleware.otp.response.Step; +import org.opentripplanner.middleware.triptracker.instruction.ContinueInstruction; import org.opentripplanner.middleware.triptracker.instruction.DeviatedInstruction; import org.opentripplanner.middleware.triptracker.instruction.GetOffHereTransitInstruction; import org.opentripplanner.middleware.triptracker.instruction.GetOffNextStopTransitInstruction; @@ -27,6 +28,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.opentripplanner.middleware.triptracker.instruction.TripInstruction.NO_INSTRUCTION; import static org.opentripplanner.middleware.triptracker.instruction.TripInstruction.TRIP_INSTRUCTION_IMMEDIATE_RADIUS; @@ -59,7 +61,7 @@ public static String getInstruction( ) { if (hasRequiredWalkLeg(travelerPosition)) { if (hasRequiredTripStatus(tripStatus)) { - TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip); + TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, false); if (tripInstruction != null) { return tripInstruction.build(); } @@ -124,7 +126,7 @@ private static TripInstruction getBackOnTrack( TravelerPosition travelerPosition, boolean isStartOfTrip ) { - TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip); + TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, true); if (instruction != null && instruction.hasInstruction()) { return instruction; } @@ -169,7 +171,8 @@ private static String getBusStopName(Leg busLeg) { @Nullable public static TripInstruction alignTravelerToTrip( TravelerPosition travelerPosition, - boolean isStartOfTrip + boolean isStartOfTrip, + boolean travelerHasDeviated ) { Locale locale = travelerPosition.locale; @@ -182,16 +185,72 @@ public static TripInstruction alignTravelerToTrip( } Step nextStep = snapToWaypoint(travelerPosition, travelerPosition.expectedLeg.steps); + TripInstruction tripInstruction = null; if (nextStep != null && (!isPositionPastStep(travelerPosition, nextStep) || isStartOfTrip)) { - return new OnTrackInstruction( + tripInstruction = new OnTrackInstruction( getDistance(travelerPosition.currentPosition, new Coordinates(nextStep)), nextStep, locale ); } + return (travelerHasDeviated || (tripInstruction != null && tripInstruction.hasInstruction())) + ? tripInstruction + : getContinueInstruction(travelerPosition, nextStep, locale); + } + + /** + * Traveler is on track, but no immediate instruction is available. Provide a "continue on street" reassurance + * instruction if the traveler is on a walk leg. This will be based on the next or previous step depending on the + * traveler's relative position to both. + */ + private static ContinueInstruction getContinueInstruction( + TravelerPosition travelerPosition, + Step nextStep, + Locale locale + ) { + if ( + Boolean.TRUE.equals(!travelerPosition.expectedLeg.transitLeg) && + travelerPosition.expectedLeg.steps != null && + !travelerPosition.expectedLeg.steps.isEmpty() + ) { + Step previousStep = getPreviousStep(travelerPosition.expectedLeg.steps, nextStep); + if (previousStep != null) { + boolean travelerBetweenSteps = isPointBetween(previousStep.toCoordinates(), nextStep.toCoordinates(), travelerPosition.currentPosition); + if (travelerBetweenSteps) { + return new ContinueInstruction(previousStep, locale); + } else if (isWithinStepRange(travelerPosition, previousStep)) { + return new ContinueInstruction(previousStep, locale); + } else if (isWithinStepRange(travelerPosition, nextStep)) { + return new ContinueInstruction(nextStep, locale); + } + } + } return null; } + /** + * The traveler is still with the provided step range. + */ + private static boolean isWithinStepRange(TravelerPosition travelerPosition, Step step) { + double distanceFromTravelerToStep = getDistance(travelerPosition.currentPosition, step.toCoordinates()); + return distanceFromTravelerToStep < step.distance; + } + + /** + * Get the step prior to the next step provided. + */ + private static Step getPreviousStep(List steps, Step nextStep) { + if (steps.get(0).equals(nextStep)) { + return null; + } + Optional previousStep = IntStream + .range(0, steps.size()) + .filter(i -> steps.get(i).equals(nextStep)) + .mapToObj(i -> steps.get(i - 1)) + .findFirst(); + return previousStep.orElse(null); + } + /** * Send bus notification if the first leg is a bus leg or approaching a bus leg and within the notify window. */ diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/instruction/ContinueInstruction.java b/src/main/java/org/opentripplanner/middleware/triptracker/instruction/ContinueInstruction.java new file mode 100644 index 000000000..d961ec6d7 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/instruction/ContinueInstruction.java @@ -0,0 +1,22 @@ +package org.opentripplanner.middleware.triptracker.instruction; + +import org.apache.logging.log4j.util.Strings; +import org.opentripplanner.middleware.otp.response.Step; + +import java.util.Locale; + +public class ContinueInstruction extends SelfLegInstruction { + public ContinueInstruction(Step legStep, Locale locale) { + this.legStep = legStep; + this.locale = locale; + } + + @Override + public String build() { + if (legStep != null && !Strings.isBlank(legStep.streetName)) { + // TODO: i18n + return String.format("Continue on %s", legStep.streetName); + } + return NO_INSTRUCTION; + } +} diff --git a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java index 3e378d8a8..251f39240 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/NotificationUtils.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.net.URI; import java.net.URLEncoder; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -32,8 +33,17 @@ import java.util.stream.Collectors; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.opentripplanner.middleware.i18n.Message.TRIP_EMAIL_FOOTER; +import static org.opentripplanner.middleware.i18n.Message.TRIP_EMAIL_MANAGE_NOTIFICATIONS; +import static org.opentripplanner.middleware.i18n.Message.TRIP_EMAIL_SUBJECT; +import static org.opentripplanner.middleware.i18n.Message.TRIP_EMAIL_SUBJECT_FOR_USER; +import static org.opentripplanner.middleware.i18n.Message.TRIP_LINK_TEXT; import static org.opentripplanner.middleware.i18n.Message.TRIP_SURVEY_NOTIFICATION; +import static org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip.ACCOUNT_PATH; +import static org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip.SETTINGS_PATH; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText; +import static org.opentripplanner.middleware.utils.I18nUtils.getOtpUserLocale; +import static org.opentripplanner.middleware.utils.I18nUtils.label; /** * This class contains utils for sending SMS, email, and push notifications. @@ -56,6 +66,9 @@ public class NotificationUtils { 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"); + private static final String OTP_UI_NAME = ConfigUtils.getConfigPropertyAsText("OTP_UI_NAME"); + private static final String OTP_UI_URL = ConfigUtils.getConfigPropertyAsText("OTP_UI_URL"); + private static final String TRIPS_PATH = ACCOUNT_PATH + "/trips"; /** * Although SMS are 160 characters long and Twilio supports sending up to 1600 characters, @@ -466,6 +479,76 @@ public static void updatePushDevices(OtpUser otpUser) { } } + /** + * Gets the localized subject line for a trip notification. + */ + public static String getTripEmailSubject(OtpUser otpUser, Locale locale, MonitoredTrip trip) { + return trip.tripName != null + ? String.format(TRIP_EMAIL_SUBJECT.get(locale), trip.tripName) + : String.format(TRIP_EMAIL_SUBJECT_FOR_USER.get(locale), otpUser.email); + } + + /** + * Replaces the sender display name with the specified user's name (fallback to the user's email). + */ + public static String replaceUserNameInFromEmail(String fromEmail, OtpUser otpUser) { + if (Strings.isBlank(fromEmail)) return fromEmail; + + int firstBracketIndex = fromEmail.indexOf('<'); + int lastBracketIndex = fromEmail.indexOf('>'); + // HACK: If falling back on email, replace the "@" sign so that the user's email does not override the + // application email in brackets. + String displayedName = Strings.isBlank(otpUser.name) ? otpUser.email.replace("@", " at ") : otpUser.name; + return String.format("%s %s", displayedName, fromEmail.substring(firstBracketIndex, lastBracketIndex + 1)); + } + + /** + * Sends a notification to a specified companion user. + */ + public static void notifyCompanion( + MonitoredTrip monitoredTrip, + OtpUser tripCreator, + OtpUser companionUser, + org.opentripplanner.middleware.i18n.Message message + ) { + if (companionUser != null) { + Locale locale = getOtpUserLocale(companionUser); + String greeting = message.get(locale); + + // A HashMap is needed instead of a Map for template data to be serialized to the template renderer. + Map templateData = new HashMap<>(); + templateData.put("emailGreeting", String.format(greeting, tripCreator.email)); + templateData.putAll(getTripNotificationFields(monitoredTrip, locale)); + + sendEmail( + replaceUserNameInFromEmail(FROM_EMAIL, tripCreator), + companionUser.email, + getTripEmailSubject(companionUser, locale, monitoredTrip), + "ShareTripText.ftl", + "ShareTripHtml.ftl", + templateData + ); + } + } + + private static String getTripUrl(MonitoredTrip trip) { + return String.format("%s%s/%s", OTP_UI_URL, TRIPS_PATH, trip.id); + } + + public static Map getTripNotificationFields(MonitoredTrip monitoredTrip, Locale locale) { + String tripLinkLabel = TRIP_LINK_TEXT.get(locale); + String tripUrl = getTripUrl(monitoredTrip); + + return Map.of( + "tripUrl", tripUrl, + "tripLinkAnchorLabel", tripLinkLabel, + "tripLinkLabelAndUrl", label(tripLinkLabel, tripUrl, locale), + "emailFooter", String.format(TRIP_EMAIL_FOOTER.get(locale), OTP_UI_NAME), + "manageLinkText", TRIP_EMAIL_MANAGE_NOTIFICATIONS.get(locale), + "manageLinkUrl", String.format("%s%s", OTP_UI_URL, SETTINGS_PATH) + ); + } + static class NotificationInfo { /** ID for tracking notifications and survey responses. */ public final String notificationId; diff --git a/src/main/resources/Message.properties b/src/main/resources/Message.properties index d0b090e68..397aae44b 100644 --- a/src/main/resources/Message.properties +++ b/src/main/resources/Message.properties @@ -29,6 +29,9 @@ TRIP_DELAY_ON_TIME = about on time TRIP_DELAY_EARLY = %s early TRIP_DELAY_LATE = %s late TRIP_DELAY_MINUTES = %d minutes +TRIP_INVITE_COMPANION = %s added you as a companion for their trip. +TRIP_INVITE_PRIMARY_TRAVELER = %s made you the primary traveler on this trip. +TRIP_INVITE_OBSERVER = %s added you as an observer for their trip. 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. diff --git a/src/main/resources/Message_fr.properties b/src/main/resources/Message_fr.properties index cc147ec24..1b178923d 100644 --- a/src/main/resources/Message_fr.properties +++ b/src/main/resources/Message_fr.properties @@ -29,6 +29,9 @@ TRIP_DELAY_ON_TIME = TRIP_DELAY_EARLY = %s en avance TRIP_DELAY_LATE = %s en retard TRIP_DELAY_MINUTES = %d minutes +TRIP_INVITE_COMPANION = %s vous a ajouté comme accompagnateur·trice pour un trajet. +TRIP_INVITE_PRIMARY_TRAVELER = %s vous a fait le voyageur principal sur ce trajet. +TRIP_INVITE_OBSERVER = %s vous a ajouté comme observateur·trice pour un trajet. 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. diff --git a/src/main/resources/templates/ShareTripHtml.ftl b/src/main/resources/templates/ShareTripHtml.ftl new file mode 100644 index 000000000..999ceef8b --- /dev/null +++ b/src/main/resources/templates/ShareTripHtml.ftl @@ -0,0 +1,15 @@ +<#ftl auto_esc=false> +<#include "OtpUserContainer.ftl"> + +<#-- + This is a template for an HTML email that gets sent when a dependent user is requesting a trusted companion. +--> + +<#macro EmailMain> +
+

${emailGreeting}

+

${tripLinkAnchorLabel}

+
+ + +<@HtmlEmail/> \ No newline at end of file diff --git a/src/main/resources/templates/ShareTripText.ftl b/src/main/resources/templates/ShareTripText.ftl new file mode 100644 index 000000000..c87ed5a0c --- /dev/null +++ b/src/main/resources/templates/ShareTripText.ftl @@ -0,0 +1,11 @@ +<#-- + This is a template for a text email that gets sent when a dependent requests a trusted companion. + + Note: in plain text emails, all whitespace is preserved, + so the indentation of the notification content is intentionally not aligned + with the indentation of the macros. + +--> +${emailGreeting} + +${tripLinkLabelAndUrl} \ No newline at end of file diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java index a1d9f0c9e..758885c28 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java @@ -212,7 +212,7 @@ void canAcceptDependentRequest() { relatedUsers .stream() .filter(user -> user.email.equals(relatedUserOne.email)) - .forEach(user -> assertEquals(RelatedUser.RelatedUserStatus.CONFIRMED, user.status)); + .forEach(user -> assertTrue(user.isConfirmed())); } @Test 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 89def54b4..9124556f7 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java @@ -16,6 +16,7 @@ import org.opentripplanner.middleware.models.TrackedJourney; import org.opentripplanner.middleware.otp.response.Itinerary; import org.opentripplanner.middleware.otp.response.Leg; +import org.opentripplanner.middleware.otp.response.Step; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.testutils.ApiTestUtils; import org.opentripplanner.middleware.testutils.CommonTestUtils; @@ -27,6 +28,10 @@ import org.opentripplanner.middleware.triptracker.TrackingLocation; import org.opentripplanner.middleware.triptracker.TripStatus; import org.opentripplanner.middleware.triptracker.TripTrackingData; +import org.opentripplanner.middleware.triptracker.instruction.ContinueInstruction; +import org.opentripplanner.middleware.triptracker.instruction.DeviatedInstruction; +import org.opentripplanner.middleware.triptracker.instruction.OnTrackInstruction; +import org.opentripplanner.middleware.triptracker.instruction.WaitForTransitInstruction; import org.opentripplanner.middleware.triptracker.payload.EndTrackingPayload; import org.opentripplanner.middleware.triptracker.payload.ForceEndTrackingPayload; import org.opentripplanner.middleware.triptracker.payload.StartTrackingPayload; @@ -39,10 +44,12 @@ import org.opentripplanner.middleware.utils.HttpResponseValues; import org.opentripplanner.middleware.utils.JsonUtils; +import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -294,88 +301,105 @@ private static Stream createInstructionAndStatusCases() { final int NORTH_WEST_BEARING = 315; final int NORTH_EAST_BEARING = 45; final int WEST_BEARING = 270; + final Locale locale = Locale.US; Leg firstLeg = itinerary.legs.get(0); - Coordinates firstStepCoords = new Coordinates(firstLeg.steps.get(0)); - Coordinates thirdStepCoords = new Coordinates(firstLeg.steps.get(2)); + Step adairAvenueNortheastStep = firstLeg.steps.get(0); + Step virginiaCircleNortheastStep = firstLeg.steps.get(1); + Step ponceDeLeonPlaceNortheastStep = firstLeg.steps.get(2); + Coordinates firstStepCoords = new Coordinates(adairAvenueNortheastStep); + Coordinates thirdStepCoords = new Coordinates(ponceDeLeonPlaceNortheastStep); Coordinates destinationCoords = new Coordinates(firstLeg.to); + String monroeDrDestinationName = firstLeg.to.name; Leg multiItinFirstLeg = multiLegItinerary.legs.get(0); - Coordinates multiItinFirstLegDestCoords = new Coordinates(multiItinFirstLeg.to); Leg multiItinLastLeg = multiLegItinerary.legs.get(multiLegItinerary.legs.size() - 1); + Leg multiItinBusLeg = multiLegItinerary.legs.get(multiLegItinerary.legs.size() - 2); + Coordinates multiItinFirstLegDestCoords = new Coordinates(multiItinFirstLeg.to); Coordinates multiItinLastLegDestCoords = new Coordinates(multiItinLastLeg.to); + String ansleyMallPetShopDestinationName = multiItinLastLeg.to.name; return Stream.of( Arguments.of( monitoredTrip, createPoint(firstStepCoords, 1, NORTH_EAST_BEARING), - "IMMEDIATE: Head WEST on Adair Avenue Northeast", + new OnTrackInstruction(1, adairAvenueNortheastStep, locale).build(), TripStatus.ON_SCHEDULE, "Coords near first step should produce relevant instruction" ), Arguments.of( monitoredTrip, createPoint(firstStepCoords, 4, NORTH_EAST_BEARING), - "UPCOMING: Head WEST on Adair Avenue Northeast", + new OnTrackInstruction(4, adairAvenueNortheastStep, locale).build(), TripStatus.DEVIATED, "Coords deviated but near first step should produce relevant instruction" ), Arguments.of( monitoredTrip, createPoint(firstStepCoords, 30, NORTH_EAST_BEARING), - "Head to Adair Avenue Northeast", + new DeviatedInstruction(adairAvenueNortheastStep.streetName, locale).build(), TripStatus.DEVIATED, "Deviated coords near first step should produce instruction to head to first step #1" ), Arguments.of( monitoredTrip, createPoint(firstStepCoords, 15, NORTH_WEST_BEARING), - "Head to Adair Avenue Northeast", + new DeviatedInstruction(adairAvenueNortheastStep.streetName, locale).build(), TripStatus.DEVIATED, "Deviated coords near first step should produce instruction to head to first step #2" ), Arguments.of( monitoredTrip, createPoint(firstStepCoords, 20, WEST_BEARING), - NO_INSTRUCTION, + new ContinueInstruction(adairAvenueNortheastStep, locale).build(), TripStatus.ON_SCHEDULE, - "Coords along a step should produce no instruction" + "Coords along a step should produce a continue on street instruction" ), Arguments.of( monitoredTrip, thirdStepCoords, - "IMMEDIATE: LEFT on Ponce de Leon Place Northeast", + new OnTrackInstruction(0, ponceDeLeonPlaceNortheastStep, locale).build(), TripStatus.AHEAD_OF_SCHEDULE, "Coords near a not-first step should produce relevant instruction" ), Arguments.of( monitoredTrip, createPoint(thirdStepCoords, 30, NORTH_WEST_BEARING), - "Head to Ponce de Leon Place Northeast", + new DeviatedInstruction(ponceDeLeonPlaceNortheastStep.streetName, locale).build(), TripStatus.DEVIATED, "Deviated coords near a not-first step should produce instruction to head to step" ), Arguments.of( monitoredTrip, createPoint(destinationCoords, 1, NORTH_WEST_BEARING), - "ARRIVED: Monroe Dr NE at Cooledge Ave NE", + new OnTrackInstruction(2, monroeDrDestinationName, locale).build(), TripStatus.COMPLETED, "Instructions for destination coordinate" ), Arguments.of( multiLegMonitoredTrip, createPoint(multiItinFirstLegDestCoords, 1.5, WEST_BEARING), - // Time is in US Pacific time zone (instead of US Eastern) by configuration for other E2E tests. - "Wait 6 minutes for your bus, route 27, scheduled at 9:18 AM, on time", + new WaitForTransitInstruction( + multiItinBusLeg, + multiItinBusLeg.getScheduledStartTime().toInstant().minus(Duration.ofMinutes(6)), + locale) + .build(), TripStatus.AHEAD_OF_SCHEDULE, "Arriving ahead of schedule to a bus stop at the end of first leg." ), Arguments.of( multiLegMonitoredTrip, createPoint(multiItinLastLegDestCoords, 1, NORTH_WEST_BEARING), - "ARRIVED: Ansley Mall Pet Shop", + new OnTrackInstruction(1, ansleyMallPetShopDestinationName, locale).build(), TripStatus.COMPLETED, "Instructions for destination coordinate of multi-leg trip" + ), + Arguments.of( + monitoredTrip, + createPoint(thirdStepCoords, 1000, NORTH_WEST_BEARING), + NO_INSTRUCTION, + TripStatus.DEVIATED, + "Deviated significantly from nearest step should produce no instruction" ) ); } diff --git a/src/test/java/org/opentripplanner/middleware/models/MonitoredTripTest.java b/src/test/java/org/opentripplanner/middleware/models/MonitoredTripTest.java index dd531e8fa..a58adec69 100644 --- a/src/test/java/org/opentripplanner/middleware/models/MonitoredTripTest.java +++ b/src/test/java/org/opentripplanner/middleware/models/MonitoredTripTest.java @@ -10,11 +10,14 @@ import org.opentripplanner.middleware.otp.response.Leg; import org.opentripplanner.middleware.otp.response.Place; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opentripplanner.middleware.models.RelatedUser.RelatedUserStatus.CONFIRMED; +import static org.opentripplanner.middleware.models.RelatedUser.RelatedUserStatus.PENDING; class MonitoredTripTest { @Test @@ -70,4 +73,81 @@ private static Stream createHasCompanionCases() { Arguments.of(unconfirmedCompanion, false) ); } + + @ParameterizedTest + @MethodSource("createGetAddedUsersCases") + void canGetAddedUsers(MonitoredTrip.TripUsers originalUsers, MonitoredTrip.TripUsers finalUsers, MonitoredTrip.TripUsers expected) { + MonitoredTrip originalTrip = new MonitoredTrip(); + originalTrip.primary = originalUsers.primary; + originalTrip.companion = originalUsers.companion; + originalTrip.observers = originalUsers.observers; + + MonitoredTrip finalTrip = new MonitoredTrip(); + finalTrip.primary = finalUsers.primary; + finalTrip.companion = finalUsers.companion; + finalTrip.observers = finalUsers.observers; + + MonitoredTrip.TripUsers tripUsers = MonitoredTrip.getAddedUsers(finalTrip, originalTrip); + + assertEquals(expected.primary != null, tripUsers.primary != null); + if (expected.primary != null && tripUsers.primary != null) { + assertEquals(expected.primary.userId, tripUsers.primary.userId); + } + assertEquals(expected.companion != null, tripUsers.companion != null); + if (expected.companion != null && tripUsers.companion != null) { + assertEquals(expected.companion.email, tripUsers.companion.email); + } + assertEquals(expected.observers, tripUsers.observers); + } + + private static Stream createGetAddedUsersCases() { + MobilityProfileLite primary = new MobilityProfileLite(); + primary.userId = "primary-user-id"; + + MobilityProfileLite newPrimary = new MobilityProfileLite(); + newPrimary.userId = "new-primary-user-id"; + + RelatedUser companion = new RelatedUser("companion@example.com", CONFIRMED); + RelatedUser newCompanion = new RelatedUser("new-companion@example.com", CONFIRMED); + RelatedUser unconfirmedCompanion = new RelatedUser("unconfirmed-companion@example.com", PENDING); + + RelatedUser observer1 = new RelatedUser("observer1@example.com", CONFIRMED); + RelatedUser observer2 = new RelatedUser("observer2@example.com", CONFIRMED); + RelatedUser observer3 = new RelatedUser("observer3@example.com", CONFIRMED); + + List observers = List.of(observer1, observer2); + + return Stream.of( + // If the final trip has the same users as the original one, no one has been added. + Arguments.of( + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(null, null, new ArrayList<>()) + ), + // If the final trip drops original users without adding new ones, no one has been added. + Arguments.of( + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(null, null, List.of()), + new MonitoredTrip.TripUsers(null, null, new ArrayList<>()) + ), + // If the original trip did not include users, users in the final trip have been added. + Arguments.of( + new MonitoredTrip.TripUsers(null, null, List.of()), + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(primary, companion, observers) + ), + // If users have been modified, the modified users should appear in added users. + Arguments.of( + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(newPrimary, newCompanion, List.of(observer1, observer3)), + new MonitoredTrip.TripUsers(newPrimary, newCompanion, List.of(observer3)) + ), + // If users have been modified, unconfirmed users should not be added. + Arguments.of( + new MonitoredTrip.TripUsers(primary, companion, observers), + new MonitoredTrip.TripUsers(newPrimary, unconfirmedCompanion, List.of(observer1, unconfirmedCompanion)), + new MonitoredTrip.TripUsers(newPrimary, null, List.of()) + ) + ); + } } diff --git a/src/test/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTripTest.java b/src/test/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTripTest.java index 9bc9a3eb6..80d90ff00 100644 --- a/src/test/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTripTest.java +++ b/src/test/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTripTest.java @@ -746,4 +746,65 @@ private void assertCheckMonitoredTrip( assertEquals(expectedAttempts, monitoredTrip.attemptsToGetMatchingItinerary); assertEquals(expectedTripStatus, monitoredTrip.journeyState.tripStatus); } + + @ParameterizedTest + @MethodSource("createCanUnsnoozeTripCases") + void canUnsnoozeTrip(ZonedDateTime lastCheckedTime, ZonedDateTime clockTime, boolean shouldUnsnooze) throws Exception { + MonitoredTrip monitoredTrip = PersistenceTestUtils.createMonitoredTrip( + user.id, + OtpTestUtils.OTP2_DISPATCHER_PLAN_RESPONSE.clone(), + false, + OtpTestUtils.createDefaultJourneyState() + ); + monitoredTrip.id = UUID.randomUUID().toString(); + // Mark trip as snoozed + monitoredTrip.snoozed = true; + // Set monitored days to Tuesday only. + monitoredTrip.monday = false; + monitoredTrip.tuesday = true; + + Persistence.monitoredTrips.create(monitoredTrip); + + // Mock the current time + DateTimeUtils.useFixedClockAt(clockTime); + + // After snoozed trip is over, trip checks on that trip should not be skipped + CheckMonitoredTrip check = new CheckMonitoredTrip(monitoredTrip, this::mockOtpPlanResponse); + + // Add artifacts of prior monitoring (e.g. monitoring was active until a few minutes before trip snooze) + JourneyState journeyState = monitoredTrip.journeyState; + journeyState.targetDate = "2020-06-09"; + journeyState.tripStatus = TripStatus.TRIP_UPCOMING; + // Set last-checked-time + journeyState.lastCheckedEpochMillis = lastCheckedTime.toInstant().toEpochMilli(); + journeyState.matchingItinerary = monitoredTrip.itinerary; + check.previousJourneyState = journeyState; + check.previousMatchingItinerary = monitoredTrip.itinerary; + + assertEquals(shouldUnsnooze, check.shouldUnsnoozeTrip()); + check.shouldSkipMonitoredTripCheck(); + + MonitoredTrip modifiedTrip = Persistence.monitoredTrips.getById(monitoredTrip.id); + assertEquals(!shouldUnsnooze, modifiedTrip.snoozed); + + // Clear the created trip. + PersistenceTestUtils.deleteMonitoredTrip(modifiedTrip); + } + + private static Stream createCanUnsnoozeTripCases() { + // (Trips for these tests start on Tuesday, June 9, 2020 at 8:40am and ends at 8:58am.) + ZonedDateTime tuesday = noonMonday8June2020.withDayOfMonth(9).withHour(0).withMinute(0).withSecond(0); + ZonedDateTime wednesday = tuesday.withDayOfMonth(10); + + return Stream.of( + // Trip snoozed at 8:00am on Tuesday, June 9, 2020, should remain snoozed right after trip ends at 9:00am. + Arguments.of(tuesday.withHour(8), tuesday.withHour(9), false), + // Trip snoozed at 8:00am on Tuesday, June 9, 2020, should unsnooze at 12:00am (midnight) on + // Wednesday, June 10, 2020, but it is too early for the trip to be analyzed again. + Arguments.of(tuesday.withHour(8), wednesday, true), + // Trip snoozed on Monday, June 8, 2020 (a day before the trip starts), should unsnooze at 12:00am (midnight) + // on Tuesday, June 9, 2020. + Arguments.of(noonMonday8June2020, tuesday, true) + ); + } } diff --git a/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java b/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java index ea6d6abc6..c7b546ed5 100644 --- a/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java +++ b/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java @@ -15,6 +15,7 @@ import org.opentripplanner.middleware.otp.response.Place; import org.opentripplanner.middleware.otp.response.Step; import org.opentripplanner.middleware.testutils.CommonTestUtils; +import org.opentripplanner.middleware.triptracker.instruction.ContinueInstruction; import org.opentripplanner.middleware.triptracker.instruction.DeviatedInstruction; import org.opentripplanner.middleware.triptracker.instruction.OnTrackInstruction; import org.opentripplanner.middleware.utils.ConfigUtils; @@ -50,6 +51,7 @@ public class ManageLegTraversalTest { private static Itinerary midtownToAnsleyItinerary; private static List midtownToAnsleyIntermediateStops; private static Itinerary firstLegBusTransit; + private static Itinerary baptistChurchToEastCroganStreetIntinerary; private static final Locale locale = Locale.US; @@ -78,6 +80,10 @@ public static void setUp() throws IOException { CommonTestUtils.getTestResourceAsString("controllers/api/first-leg-transit.json"), Itinerary.class ); + baptistChurchToEastCroganStreetIntinerary = JsonUtils.getPOJOFromJSON( + CommonTestUtils.getTestResourceAsString("controllers/api/baptist-church-to-east-crogan-street.json"), + Itinerary.class + ); // Hold on to the original list of intermediate stops (some tests will overwrite it) midtownToAnsleyIntermediateStops = midtownToAnsleyItinerary.legs.get(1).intermediateStops; } @@ -208,6 +214,11 @@ private static Stream createTurnByTurnTrace() { Coordinates busStopCoords = new Coordinates(firstBusLeg.from); String busStopName = firstBusLeg.from.name; + Leg toEastCroganFirstLeg = baptistChurchToEastCroganStreetIntinerary.legs.get(0); + Step southClaytonSt = toEastCroganFirstLeg.steps.get(1); + Step eastCroganSt = toEastCroganFirstLeg.steps.get(2); + Coordinates pointOnSouthClaytonSt = new Coordinates(33.955561, -83.988204); + return Stream.of( Arguments.of( firstBusLeg, @@ -270,9 +281,9 @@ private static Stream createTurnByTurnTrace() { walkLeg, new TraceData( createPoint(virginiaCircleNortheastCoords, 12, SOUTH_WEST_BEARING), - NO_INSTRUCTION, + new ContinueInstruction(virginiaCircleNortheastStep, locale).build(), false, - "On track approaching second step, but not close enough for instruction." + "On track approaching second step, provide continue instruction." ) ), Arguments.of( @@ -336,9 +347,9 @@ private static Stream createTurnByTurnTrace() { walkLeg, new TraceData( createPoint(pointAfterTurn, 0, calculateBearing(pointAfterTurn, virginiaAvenuePoint)), - NO_INSTRUCTION, + new ContinueInstruction(virginiaAvenueNortheastStep, locale).build(), false, - "After turn left on to Virginia Avenue should not produce turn instruction." + "After turn left on to Virginia Avenue should provide continue instruction." ) ), Arguments.of( @@ -358,6 +369,33 @@ private static Stream createTurnByTurnTrace() { false, "On destination instruction." ) + ), + Arguments.of( + toEastCroganFirstLeg, + new TraceData( + pointOnSouthClaytonSt, + new ContinueInstruction(southClaytonSt, locale).build(), + false, + "On track passed second step and not near to next step, provide continue instruction for second step." + ) + ), + Arguments.of( + toEastCroganFirstLeg, + new TraceData( + createPoint(pointOnSouthClaytonSt, 12, NORTH_WEST_BEARING), + new ContinueInstruction(southClaytonSt, locale).build(), + false, + "On track a bit near to the next step, provide continue instruction for second step." + ) + ), + Arguments.of( + toEastCroganFirstLeg, + new TraceData( + createPoint(pointOnSouthClaytonSt, 72, NORTH_BEARING), + new ContinueInstruction(eastCroganSt, locale).build(), + false, + "On track passed next step, provide continue instruction for next step." + ) ) ); } diff --git a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java index 22f6b546e..7c09326b0 100644 --- a/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java +++ b/src/test/java/org/opentripplanner/middleware/utils/NotificationUtilsTest.java @@ -142,4 +142,29 @@ static Stream createNotificationInfoCases() { ) ); } + + @ParameterizedTest + @MethodSource("createCanReplaceUserNameInFromEmailCases") + void canReplaceUserNameInFromEmail(String emailAlias, OtpUser user, String expectedPrefix) { + String fromEmail = emailAlias != null ? String.format("RideGuide %s", emailAlias) : null; + String expected = fromEmail != null ? fromEmail.replace("RideGuide", expectedPrefix) : null; + assertEquals(expected, NotificationUtils.replaceUserNameInFromEmail(fromEmail, user)); + } + + private static Stream createCanReplaceUserNameInFromEmailCases() { + OtpUser user1 = new OtpUser(); + user1.email = "user1@example.com"; + + OtpUser user2 = new OtpUser(); + user2.email = "user2@example.com"; + user2.name = "Joan Smith"; + + String emailAlias = ""; + + return Stream.of( + Arguments.of(emailAlias, user1, "user1 at example.com"), + Arguments.of(null, user1, null), + Arguments.of(emailAlias, user2, user2.name) + ); + } } diff --git a/src/test/resources/org/opentripplanner/middleware/controllers/api/baptist-church-to-east-crogan-street.json b/src/test/resources/org/opentripplanner/middleware/controllers/api/baptist-church-to-east-crogan-street.json new file mode 100644 index 000000000..0198cf5d0 --- /dev/null +++ b/src/test/resources/org/opentripplanner/middleware/controllers/api/baptist-church-to-east-crogan-street.json @@ -0,0 +1,107 @@ +{ + "duration": 478, + "startTime": 1731606660000, + "endTime": 1731607138000, + "walkTime": 478, + "transitTime": 0, + "waitingTime": 0, + "walkDistance": 0, + "walkLimitExceeded": false, + "elevationLost": 0, + "elevationGained": 0, + "transfers": 0, + "fare": null, + "legs": [ + { + "startTime": 1731606660000, + "endTime": 1731607138000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 636.31, + "mode": "WALK", + "interlineWithPreviousLeg": false, + "from": { + "name": "First Baptist Church of Lawrenceville, Lawrenceville, GA, USA", + "lon": -83.9881534, + "lat": 33.9549491, + "vertexType": "NORMAL" + }, + "to": { + "name": "49 East Crogan Street, Lawrenceville, GA, USA", + "lon": -83.9833418, + "lat": 33.9565218, + "vertexType": "NORMAL" + }, + "legGeometry": { + "points": "myfnEz|r_OG@G?A?wBLI?I@_@?{@DG?Ek@GeBCk@G?I?I?A?AK?G?E?a@?]?AAE?G?M?M?E?EGwB?EAEAGAC?A@?@Y@[?C@@H@L@@?ACAAAKAQ?U?KAKAeA?O?K?KA]?K?KAg@?IAO?KE{B", + "length": 62 + }, + "rentedBike": false, + "transitLeg": false, + "duration": 478, + "steps": [ + { + "distance": 10.11, + "relativeDirection": "DEPART", + "streetName": "crossing over Luckie Street☆☆☆", + "absoluteDirection": "NORTH", + "stayOn": false, + "area": false, + "lon": -83.9881362, + "lat": 33.9549515 + }, + { + "distance": 134.71, + "relativeDirection": "CONTINUE", + "streetName": "South Clayton Street★★★", + "absoluteDirection": "NORTH", + "stayOn": false, + "area": false, + "lon": -83.9881458, + "lat": 33.955042 + }, + { + "distance": 88.77, + "relativeDirection": "RIGHT", + "streetName": "East Crogan Street★★★", + "absoluteDirection": "EAST", + "stayOn": true, + "area": false, + "lon": -83.9882578, + "lat": 33.9562495 + }, + { + "distance": 17.43, + "relativeDirection": "LEFT", + "streetName": "crossing over East Crogan Street☆☆☆", + "absoluteDirection": "NORTH", + "stayOn": true, + "area": false, + "lon": -83.9873003, + "lat": 33.9563302 + }, + { + "distance": 177.64, + "relativeDirection": "RIGHT", + "streetName": "East Crogan Street★★★", + "absoluteDirection": "EAST", + "stayOn": false, + "area": false, + "lon": -83.9873097, + "lat": 33.9564868 + }, + { + "distance": 207.65, + "relativeDirection": "LEFT", + "streetName": "East Crogan Street★★★", + "absoluteDirection": "EAST", + "stayOn": true, + "area": false, + "lon": -83.985582, + "lat": 33.9564082 + } + ] + } + ] +} \ No newline at end of file