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

Trip sharing emails #273

Merged
merged 20 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c1c972
chore: Add email templates for trip sharing.
binh-dam-ibigroup Nov 21, 2024
9c3eba8
Resolve conflicts
binh-dam-ibigroup Nov 22, 2024
eb5c8e2
refactor(MonitoredTrip): Add logic for extracting added users.
binh-dam-ibigroup Nov 25, 2024
1e6c4aa
test(MonitoredTrip): Refactor tests.
binh-dam-ibigroup Nov 25, 2024
4cc7b6f
refactor(RelatedUser): Add isConfirmed method
binh-dam-ibigroup Nov 25, 2024
b0015b2
refactor(MonitoredTrip): Reduce getAddedUsers complexity level
binh-dam-ibigroup Nov 25, 2024
147d2ab
refactor(NotificationUtils): Extract method to get trip email subject.
binh-dam-ibigroup Nov 25, 2024
615caad
docs(swagger): Update snapshot.
binh-dam-ibigroup Nov 26, 2024
34aed52
refactor(NotificationUtils): Move companion notification code to Noti…
binh-dam-ibigroup Nov 26, 2024
1f95104
refactor(NotificationUtils): Add i18n to messages, add more tests.
binh-dam-ibigroup Nov 26, 2024
a6b9c40
perf(NotificationUtils): Extract trip creator before sending various …
binh-dam-ibigroup Nov 26, 2024
2a64b88
refactor(NotificationUtils): Extract trip email attributes.
binh-dam-ibigroup Nov 26, 2024
941f34c
docs(swagger): Update snapshot.
binh-dam-ibigroup Nov 26, 2024
be2f456
refactor(RelatedUser): Hide isConfirmed from serialization.
binh-dam-ibigroup Nov 26, 2024
c1aa232
fix(NotificationUtils): Edit special char if using fallback email.
binh-dam-ibigroup Nov 26, 2024
e675869
refactor(NotificationUtils): Remove companion type enum.
binh-dam-ibigroup Nov 26, 2024
4f84f69
refactor: Add light touch ups.
binh-dam-ibigroup Nov 26, 2024
9fd7d5e
Merge branch 'dev' into trip-sharing-emails
binh-dam-ibigroup Dec 4, 2024
02d74f9
test(MonitoredTripTest): Fix incorrect var assignment.
binh-dam-ibigroup Dec 5, 2024
2bdfff0
Merge branch 'dev' into trip-sharing-emails
binh-dam-ibigroup Dec 6, 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 @@ -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;
Expand All @@ -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}.
Expand Down Expand Up @@ -90,6 +97,8 @@ MonitoredTrip preCreateHook(MonitoredTrip monitoredTrip, Request req) {
}
}

notifyTripCompanionsAndObservers(monitoredTrip, null);

return monitoredTrip;
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
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
Expand Down Expand Up @@ -427,4 +429,66 @@ public int tripTimeMinute() {
public boolean isOneTime() {
return !monday && !tuesday && !wednesday && !thursday && !friday && !saturday && !sunday;
}

/**
* 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.companion != null && monitoredTrip.companion.isConfirmed() && (
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<RelatedUser> addedObservers = new ArrayList<>();
if (monitoredTrip.observers != null) {
List<RelatedUser> confirmedObservers = monitoredTrip.observers.stream()
.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<String> 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<RelatedUser> observers;
public final MobilityProfileLite primary;

public TripUsers(MobilityProfileLite primary, RelatedUser companion, List<RelatedUser> observers) {
this.primary = primary;
this.companion = companion;
this.observers = observers;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -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;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,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;
Expand Down Expand Up @@ -529,21 +527,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<String, Object> templateData = new HashMap<>(Map.of(
Map<String, Object> 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);
}
Expand Down Expand Up @@ -588,9 +580,7 @@ private boolean sendPush(OtpUser otpUser, Map<String, Object> data) {
*/
private boolean sendEmail(OtpUser otpUser, Map<String, Object> 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,
Expand Down Expand Up @@ -925,8 +915,4 @@ private OtpUser getOtpUser() {
private Locale getOtpUserLocale() {
return I18nUtils.getOtpUserLocale(getOtpUser());
}

private String getTripUrl() {
return String.format("%s%s/%s", OTP_UI_URL, TRIPS_PATH, trip.id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,25 @@
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;
import java.util.Objects;
import java.util.stream.Collectors;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.opentripplanner.middleware.i18n.Message.TRIP_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.
Expand All @@ -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,
Expand Down Expand Up @@ -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<String, Object> 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<String, Object> 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;
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/Message.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/Message_fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading