Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Post deviated trip notification #260

Draft
wants to merge 13 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +85,16 @@ public static void main(String[] args) throws IOException, InterruptedException
1,
TimeUnit.MINUTES
);

// Schedule recurring job for post-trip surveys, once every few hours
// TODO: Determine whether this should go in some other process.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the trigger location is fine. Perhaps follow the approach of ConnectedDataManager.scheduleTripHistoryUploadJob(); and have the schduler in the class. It might also be benefical to have the ability to disable this via a config property.

TripSurveySenderJob tripSurveySenderJob = new TripSurveySenderJob();
Scheduler.scheduleJob(
tripSurveySenderJob,
0,
12,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to send notifications potentially in the early hours of the morning? Do we care? Might get a better response in working hours.

TimeUnit.HOURS
);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public abstract class ApiController<T extends Model> 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)
Expand Down Expand Up @@ -219,7 +220,7 @@ private ResponseList<T> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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;
Expand Down Expand Up @@ -216,7 +217,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 LAST_TRIP_SURVEY_NOTIF_SENT_FIELD = "lastTripSurveyNotificationSent";

/** Whether the user would like accessible routes by default. */
public boolean accessibilityRoutingByDefault;
Expand Down Expand Up @@ -76,6 +77,9 @@ public enum Notification {
/** Whether to store the user's trip history (user must opt in). */
public boolean storeTripHistory;

/** When the last post-trip survey notification was sent. */
public Date lastTripSurveyNotificationSent;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only referenced in tests. I think it is updated via the field reference above, so is required at a DB level?


@JsonIgnore
/** If this user was created by an {@link ApiUser}, this parameter will match the {@link ApiUser}'s id */
public String applicationId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public class TrackedJourney extends Model {

public Map<String, String> busNotificationMessages = new HashMap<>();

public Double totalDeviation;

public transient MonitoredTrip trip;

public static final String TRIP_ID_FIELD_NAME = "tripId";

public static final String LOCATIONS_FIELD_NAME = "locations";
Expand All @@ -35,6 +39,8 @@ public class TrackedJourney extends Model {

public static final String END_CONDITION_FIELD_NAME = "endCondition";

public static final String TOTAL_DEVIATION_FIELD_NAME = "totalDeviation";

public static final String TERMINATED_BY_USER = "Tracking terminated by user.";

public static final String FORCIBLY_TERMINATED = "Tracking forcibly terminated.";
Expand Down Expand Up @@ -91,4 +97,14 @@ public void updateNotificationMessage(String routeId, String body) {
busNotificationMessages
);
}

/** The sum of the deviations for all tracking locations that have it. */
public double computeTotalDeviation() {
if (locations == null) return -1;

return locations.stream()
.filter(l -> l.deviationMeters != null)
.map(l -> l.deviationMeters)
.reduce(0.0, Double::sum);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<String> allTripIds = Persistence.monitoredTrips.getDistinctFieldValues(
"_id",
ID_FIELD_NAME,
makeTripFilter(),
String.class
).into(new ArrayList<>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ private static TrackingResponse doUpdateTracking(Request request, TripTrackingDa
);
TripStatus tripStatus = TripStatus.getTripStatus(travelerPosition);
trackedJourney.lastLocation().tripStatus = tripStatus;
trackedJourney.lastLocation().deviationMeters = travelerPosition.getDeviationMeters();

if (create) {
Persistence.trackedJourneys.create(trackedJourney);
Expand Down Expand Up @@ -151,6 +152,8 @@ 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.totalDeviation = trackedJourney.computeTotalDeviation();
Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.TOTAL_DEVIATION_FIELD_NAME, trackedJourney.totalDeviation);

// Provide response.
return new EndTrackingResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ public class TrackingLocation {

public Date timestamp;

/** Deviation or on-time status computed for this location. */
public TripStatus tripStatus;

/** FIXME: Device location accuracy, reported by the device in a unit TBD. For reporting only. */
public Double locationAccuracy;

/** Perpendicular deviation, computed in meters, to the path closest to this location. */
public Double deviationMeters;

public TrackingLocation() {
// Needed for deserializing objects.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getExpectedLeg;
import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getNextLeg;
import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getSegmentFromPosition;
import static org.opentripplanner.middleware.utils.GeometryUtils.getDistanceFromLine;

public class TravelerPosition {

Expand Down Expand Up @@ -81,4 +82,9 @@ public TravelerPosition(Leg nextLeg, Instant currentTime) {
this.nextLeg = nextLeg;
this.currentTime = currentTime;
}

/** Computes the current deviation in meters from the expected itinerary. */
public double getDeviationMeters() {
return getDistanceFromLine(legSegmentFromPosition.start, legSegmentFromPosition.end, currentPosition);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,7 @@ public static Instant getSegmentStartTime(TravelerPosition travelerPosition) {
* Checks if the traveler's position is with an acceptable distance of the mode type.
*/
private static boolean isWithinModeRadius(TravelerPosition travelerPosition) {
double distanceFromExpected = getDistanceFromLine(
travelerPosition.legSegmentFromPosition.start,
travelerPosition.legSegmentFromPosition.end,
travelerPosition.currentPosition
);
double distanceFromExpected = travelerPosition.getDeviationMeters();
double modeBoundary = getModeRadius(travelerPosition.legSegmentFromPosition.mode);
return distanceFromExpected <= modeBoundary;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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.persistence.Persistence;
import org.opentripplanner.middleware.utils.DateTimeUtils;
import org.opentripplanner.middleware.utils.I18nUtils;
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.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.LAST_TRIP_SURVEY_NOTIF_SENT_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;

/**
* 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<OtpUser> usersWithNotificationsOverAWeekAgo = getUsersWithNotificationsOverAWeekAgo();

// Collect journeys that were completed/terminated in the past 24-48 hrs. (skip ongoing journeys).
List<TrackedJourney> journeysCompletedInPast24To48Hours = getCompletedJourneysInPast24To48Hours();

// Map users to journeys.
Map<OtpUser, List<TrackedJourney>> usersToJourneys = mapJourneysToUsers(journeysCompletedInPast24To48Hours, usersWithNotificationsOverAWeekAgo);

for (Map.Entry<OtpUser, List<TrackedJourney>> entry : usersToJourneys.entrySet()) {
// Find journey with the largest total deviation.
Optional<TrackedJourney> optJourney = selectMostDeviatedJourney(entry.getValue());
if (optJourney.isPresent()) {
// Send push notification about that journey.
OtpUser otpUser = entry.getKey();
TrackedJourney journey = optJourney.get();
MonitoredTrip trip = journey.trip;
Map<String, Object> data = new HashMap<>();
data.put("tripDay", DateTimeUtils.makeOtpZonedDateTime(journey.startTime).getDayOfWeek());
data.put("tripTime", DateTimeUtils.formatShortDate(trip.itinerary.startTime, I18nUtils.getOtpUserLocale(otpUser)));
NotificationUtils.sendPush(otpUser, "PostTripSurveyPush.ftl", data, trip.tripName, trip.id);

// Store time of last sent survey notification for user.
Persistence.otpUsers.updateField(otpUser.id, LAST_TRIP_SURVEY_NOTIF_SENT_FIELD, new Date());
}
}

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<OtpUser> getUsersWithNotificationsOverAWeekAgo() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is or should there be a option to opt-out of receiving surveys?

Date aWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
Bson dateFilter = Filters.lte(LAST_TRIP_SURVEY_NOTIF_SENT_FIELD, aWeekAgo);
Bson surveyNotSentFilter = Filters.not(Filters.exists(LAST_TRIP_SURVEY_NOTIF_SENT_FIELD));
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 24 hours.
*/
public static List<TrackedJourney> getCompletedJourneysInPast24To48Hours() {
Date twentyFourHoursAgo = Date.from(Instant.now().minus(24, ChronoUnit.HOURS));
Date fortyEightHoursAgo = Date.from(Instant.now().minus(48, ChronoUnit.HOURS));
Bson dateFilter = Filters.and(
Filters.gte(END_TIME_FIELD_NAME, fortyEightHoursAgo),
Filters.lte(END_TIME_FIELD_NAME, twentyFourHoursAgo)
);
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<MonitoredTrip> getTripsForJourneysAndUsers(List<TrackedJourney> journeys, List<OtpUser> otpUsers) {
Set<String> tripIds = journeys.stream().map(j -> j.tripId).collect(Collectors.toSet());
Set<String> 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<OtpUser, List<TrackedJourney>> mapJourneysToUsers(List<TrackedJourney> journeys, List<OtpUser> otpUsers) {
List<MonitoredTrip> trips = getTripsForJourneysAndUsers(journeys, otpUsers);

Map<String, OtpUser> userMap = otpUsers.stream().collect(Collectors.toMap(u -> u.id, Function.identity()));

HashMap<OtpUser, List<TrackedJourney>> map = new HashMap<>();
for (MonitoredTrip trip : trips) {
List<TrackedJourney> 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<TrackedJourney> selectMostDeviatedJourney(List<TrackedJourney> journeys) {
if (journeys == null) return Optional.empty();
return journeys.stream().max(Comparator.comparingDouble(j -> j.totalDeviation));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static double getDistance(Coordinates start, Coordinates end) {
}

/**
* Get the distance between a line and point.
* Get the distance in meters between a line and point.
*/
public static double getDistanceFromLine(Coordinates start, Coordinates end, Coordinates traveler) {
double[] startXY = convertLatLonToXY(start.lat, start.lon);
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/latest-spark-swagger-output.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2772,6 +2772,12 @@ definitions:
- "AHEAD_OF_SCHEDULE"
- "ENDED"
- "DEVIATED"
locationAccuracy:
type: "number"
format: "double"
deviationMeters:
type: "number"
format: "double"
TrackingResponse:
type: "object"
properties:
Expand Down Expand Up @@ -2960,6 +2966,9 @@ definitions:
$ref: "#/definitions/UserLocation"
storeTripHistory:
type: "boolean"
lastTripSurveyNotificationSent:
type: "string"
format: "date"
applicationId:
type: "string"
MobilityProfile:
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/MonitoredTripPush.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
Expand Down
Loading
Loading