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 21 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9fc6822
feat(TrackedJourney): Add method to compute total deviation.
binh-dam-ibigroup Oct 4, 2024
6507d5a
feat(TrackedJourney): Persist total deviation upon completing journey.
binh-dam-ibigroup Oct 4, 2024
44c456e
.feat(OtpUser): Add field for last survey notif sent.
binh-dam-ibigroup Oct 8, 2024
9e19b1e
refactor(ApiController): Extract const for mongo id field.
binh-dam-ibigroup Oct 8, 2024
462639b
refactor(MonitoredTrip): Extract const for trip id field.
binh-dam-ibigroup Oct 8, 2024
1f31842
feat(TripSurveySenderJob): Add basic logic for trip survey job.
binh-dam-ibigroup Oct 8, 2024
2e2473f
refactor(TripSurveySenderJob): Convert methods to static.
binh-dam-ibigroup Oct 8, 2024
2432090
test(TripSurveySenderJob): Reuse journeys.
binh-dam-ibigroup Oct 8, 2024
c00ac92
feat(TripSurveySenderJob): Send push notification using current svc f…
binh-dam-ibigroup Oct 9, 2024
ca2b8aa
feat(OtpMiddlewareMain): Schedule trip survey job.
binh-dam-ibigroup Oct 9, 2024
20fdb33
docs(swagger): Update snapshot.
binh-dam-ibigroup Oct 9, 2024
ab5a82f
fix(TripSurveySenderJob): Include missing last trip survey field in u…
binh-dam-ibigroup Oct 9, 2024
828afdc
refactor(TripSurveySenderJob): Remove day of week in survey push mess…
binh-dam-ibigroup Oct 9, 2024
3ed298b
docs(OtpMiddlewareMain): Fix trip survey comment typo.
binh-dam-ibigroup Oct 10, 2024
fa6b838
refactor(TrackedJourney): Introduce consecutive deviation metric.
binh-dam-ibigroup Oct 10, 2024
72ecf80
refactor(TripSurveySenderJob): Add a minimum 1-minute deviation thres…
binh-dam-ibigroup Oct 10, 2024
e46abca
Merge branch 'dev' into post-deviated-trip-notification
binh-dam-ibigroup Oct 14, 2024
25d19f9
Merge branch 'dev' into post-deviated-trip-notification
binh-dam-ibigroup Oct 21, 2024
be37335
refactor(TrackedJourney): Remove cumulative deviation field.
binh-dam-ibigroup Oct 21, 2024
653b62c
Merge branch 'dev' into post-deviated-trip-notification
binh-dam-ibigroup Oct 25, 2024
eb59c11
fix(TripSurveySenderJob): Send survey notifications within 30 mins of…
binh-dam-ibigroup Oct 25, 2024
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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Morning local time sounds good. Do you know how to do that with this scheduler?

Copy link
Contributor

Choose a reason for hiding this comment

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

Workout the diff between now and the next 9am in seconds and use that as the initial delay. Time unit used by the scheduler would have to be seconds.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Change on when notifications are sent: within 20-30 minutes of trip completion.

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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, this field is needed to keep track of when a post-travel notification was sent when subsequent trips are completed.


@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 @@ -152,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
@@ -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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No opt-out of post-travel surveys at the moment.

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));
}
}
3 changes: 3 additions & 0 deletions src/main/resources/latest-spark-swagger-output.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2966,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
9 changes: 9 additions & 0 deletions src/main/resources/templates/PostTripSurveyPush.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<#--
This is a template for push notifications content for notifications
after an OTP user takes a monitored trip and completes travel.
Note the following character limitations by mobile OS:
- iOS: 178 characters over up to 4 lines,
- Android: 240 characters (excluding notification title).
The max length is thus 178 characters.
-->
How was your trip on ${tripDay} at ${tripTime}? Tap for a quick survey.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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;
Expand Down Expand Up @@ -169,6 +171,11 @@ 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);
assertNotNull(mongoTrackedJourney.totalDeviation);
assertNotEquals(0.0, mongoTrackedJourney.totalDeviation);
DateTimeUtils.useSystemDefaultClockAndTimezone();
}

Expand Down Expand Up @@ -316,6 +323,12 @@ 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);
assertNotNull(mongoTrackedJourney.totalDeviation);
assertNotEquals(0.0, mongoTrackedJourney.totalDeviation);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.opentripplanner.middleware.models;

import org.junit.jupiter.api.Test;
import org.opentripplanner.middleware.triptracker.TrackingLocation;

import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;

class TrackedJourneyTest {
@Test
void canComputeTotalDeviation() {
TrackedJourney journey = new TrackedJourney();
journey.locations = null;
assertEquals(-1.0, journey.computeTotalDeviation());

journey.locations = Stream
.of(11.0, 23.0, 6.4)
.map(d -> {
TrackingLocation location = new TrackingLocation();
location.deviationMeters = d;
return location;
})
.collect(Collectors.toList());
assertEquals(40.4, journey.computeTotalDeviation());
}
}
Loading
Loading