diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java index f0a0c3db5..39bce3be6 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java @@ -1,16 +1,15 @@ package org.opentripplanner.middleware.controllers.api; -import io.github.manusant.ss.ApiEndpoint; import com.twilio.rest.verify.v2.service.Verification; import com.twilio.rest.verify.v2.service.VerificationCheck; +import io.github.manusant.ss.ApiEndpoint; import org.apache.commons.lang3.StringUtils; import org.eclipse.jetty.http.HttpStatus; import org.opentripplanner.middleware.auth.Auth0Connection; import org.opentripplanner.middleware.auth.RequestingUser; import org.opentripplanner.middleware.models.OtpUser; -import org.opentripplanner.middleware.models.RelatedUser; import org.opentripplanner.middleware.persistence.Persistence; -import org.opentripplanner.middleware.utils.HttpUtils; +import org.opentripplanner.middleware.tripmonitor.TrustedCompanion; import org.opentripplanner.middleware.utils.JsonUtils; import org.opentripplanner.middleware.utils.NotificationUtils; import org.opentripplanner.middleware.utils.SwaggerUtils; @@ -25,6 +24,7 @@ import java.util.regex.Pattern; import static io.github.manusant.ss.descriptor.MethodDescriptor.path; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.manageAcceptDependentEmail; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; /** @@ -35,11 +35,18 @@ public class OtpUserController extends AbstractUserController { private static final Logger LOG = LoggerFactory.getLogger(OtpUserController.class); private static final String CODE_PARAM = "code"; + private static final String PHONE_PARAM = "phoneNumber"; + private static final String VERIFY_PATH = "verify_sms"; + public static final String OTP_USER_PATH = "secure/user"; + private static final String VERIFY_ROUTE_TEMPLATE = "/:%s/%s/:%s"; - /** Regex to check E.164 phone number format per https://www.twilio.com/docs/glossary/what-e164 */ + + /** + * Regex to check E.164 phone number format per https://www.twilio.com/docs/glossary/what-e164 + */ private static final Pattern PHONE_E164_PATTERN = Pattern.compile("^\\+[1-9]\\d{1,14}$"); public OtpUserController(String apiPrefix) { @@ -58,6 +65,7 @@ OtpUser preCreateHook(OtpUser user, Request req) { if (Objects.nonNull(user.mobilityProfile)) { user.mobilityProfile.updateMobilityMode(); } + manageAcceptDependentEmail(user); return super.preCreateHook(user, req); } @@ -66,6 +74,7 @@ OtpUser preUpdateHook(OtpUser user, OtpUser preExistingUser, Request req) { if (Objects.nonNull(user.mobilityProfile)) { user.mobilityProfile.updateMobilityMode(); } + manageAcceptDependentEmail(user); return super.preUpdateHook(user, preExistingUser, req); } @@ -83,7 +92,7 @@ protected void buildEndpoint(ApiEndpoint baseEndpoint) { .withRequired(true) .withDescription("The dependent user id.") .and(), - OtpUserController::acceptDependent + TrustedCompanion::acceptDependent ) .get(path(ROOT_ROUTE + String.format(VERIFY_ROUTE_TEMPLATE, ID_PARAM, VERIFY_PATH, PHONE_PARAM)) .withDescription("Request an SMS verification to be sent to an OtpUser's phone number.") @@ -195,51 +204,4 @@ public static boolean isPhoneNumberValidE164(String phoneNumber) { Matcher m = PHONE_E164_PATTERN.matcher(phoneNumber); return m.matches(); } - - /** - * Accept a request from another user to be their dependent. This will include both companions and observers. - */ - private static OtpUser acceptDependent(Request request, Response response) { - RequestingUser requestingUser = Auth0Connection.getUserFromRequest(request); - OtpUser relatedUser = requestingUser.otpUser; - if (relatedUser == null) { - logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Otp user unknown."); - return null; - } - - String dependentUserId = HttpUtils.getQueryParamFromRequest(request, USER_ID_PARAM, false); - if (dependentUserId.isEmpty()) { - logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent user id not provided."); - return null; - } - - OtpUser dependentUser = Persistence.otpUsers.getById(dependentUserId); - if (dependentUser == null) { - logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent user unknown!"); - return null; - } - - boolean isRelated = dependentUser.relatedUsers - .stream() - .filter(related -> related.userId.equals(relatedUser.id)) - // Update related user status. This assumes a related user with "pending" status was previously added. - .peek(related -> related.status = RelatedUser.RelatedUserStatus.CONFIRMED) - .findFirst() - .isPresent(); - - if (isRelated) { - // Maintain a list of dependents. - relatedUser.dependents.add(dependentUserId); - Persistence.otpUsers.replace(relatedUser.id, relatedUser); - // Update list of related users. - Persistence.otpUsers.replace(dependentUser.id, dependentUser); - } else { - logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent did not request user to be related!"); - return null; - } - - // TODO: Not sure what is required in the response. For now, returning the updated related user. - return relatedUser; - } - -} +} \ No newline at end of file diff --git a/src/main/java/org/opentripplanner/middleware/i18n/Message.java b/src/main/java/org/opentripplanner/middleware/i18n/Message.java index d3946e31f..da82bca0e 100644 --- a/src/main/java/org/opentripplanner/middleware/i18n/Message.java +++ b/src/main/java/org/opentripplanner/middleware/i18n/Message.java @@ -13,6 +13,11 @@ * Message.properties. */ public enum Message { + ACCEPT_DEPENDENT_EMAIL_FOOTER, + ACCEPT_DEPENDENT_EMAIL_GREETING, + ACCEPT_DEPENDENT_EMAIL_LINK_TEXT, + ACCEPT_DEPENDENT_EMAIL_SUBJECT, + ACCEPT_DEPENDENT_EMAIL_MANAGE, LABEL_AND_CONTENT, SMS_STOP_NOTIFICATIONS, TRIP_EMAIL_SUBJECT, diff --git a/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java index 640a53d72..e16473d4a 100644 --- a/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java @@ -9,6 +9,7 @@ public enum RelatedUserStatus { public String userId; public String email; public RelatedUserStatus status; + public boolean acceptDependentEmailSent; public RelatedUser() { // Required for JSON deserialization. diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java new file mode 100644 index 000000000..c75f1a3d0 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java @@ -0,0 +1,141 @@ +package org.opentripplanner.middleware.tripmonitor; + +import org.eclipse.jetty.http.HttpStatus; +import org.opentripplanner.middleware.auth.Auth0Connection; +import org.opentripplanner.middleware.auth.RequestingUser; +import org.opentripplanner.middleware.i18n.Message; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.RelatedUser; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.utils.ConfigUtils; +import org.opentripplanner.middleware.utils.HttpUtils; +import org.opentripplanner.middleware.utils.I18nUtils; +import org.opentripplanner.middleware.utils.NotificationUtils; +import spark.Request; +import spark.Response; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import static org.opentripplanner.middleware.controllers.api.ApiController.USER_ID_PARAM; +import static org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip.SETTINGS_PATH; +import static org.opentripplanner.middleware.utils.I18nUtils.label; +import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; + +public class TrustedCompanion { + + private TrustedCompanion() { + throw new IllegalStateException("Utility class"); + } + + private static final String OTP_UI_URL = ConfigUtils.getConfigPropertyAsText("OTP_UI_URL"); + + public static final String ACCEPT_DEPENDENT_PATH = "api/secure/user/acceptdependent"; + + /** + * Accept a request from another user to be their dependent. This will include both companions and observers. + */ + public static OtpUser acceptDependent(Request request, Response response) { + RequestingUser requestingUser = Auth0Connection.getUserFromRequest(request); + OtpUser relatedUser = requestingUser.otpUser; + if (relatedUser == null) { + logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Otp user unknown."); + return null; + } + + String dependentUserId = HttpUtils.getQueryParamFromRequest(request, USER_ID_PARAM, false); + if (dependentUserId.isEmpty()) { + logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent user id not provided."); + return null; + } + + OtpUser dependentUser = Persistence.otpUsers.getById(dependentUserId); + if (dependentUser == null) { + logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent user unknown!"); + return null; + } + + boolean isRelated = dependentUser.relatedUsers + .stream() + .filter(related -> related.userId.equals(relatedUser.id)) + // Update related user status. This assumes a related user with "pending" status was previously added. + .peek(related -> related.status = RelatedUser.RelatedUserStatus.CONFIRMED) + .findFirst() + .isPresent(); + + if (isRelated) { + // Maintain a list of dependents. + relatedUser.dependents.add(dependentUserId); + Persistence.otpUsers.replace(relatedUser.id, relatedUser); + // Update list of related users. + Persistence.otpUsers.replace(dependentUser.id, dependentUser); + } else { + logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Dependent did not request user to be related!"); + return null; + } + + // TODO: Not sure what is required in the response. For now, returning the updated related user. + return relatedUser; + } + + /** + * When creating or updating an OTP user, extract a list of newly defined dependents and send an 'accept dependent' + * email to each. Then update which dependents have been sent an email so subsequent updates do not trigger + * additional emails. + */ + public static void manageAcceptDependentEmail(OtpUser dependentUser) { + if (dependentUser.relatedUsers.isEmpty()) { + // No related users defined by dependent. + return; + } + + dependentUser.relatedUsers + .stream() + .filter(relatedUser -> !relatedUser.acceptDependentEmailSent) + .forEach(relatedUser -> { + OtpUser user = Persistence.otpUsers.getById(relatedUser.userId); + if (user != null && sendAcceptDependentEmail(dependentUser, user)) { + relatedUser.acceptDependentEmailSent = true; + } + }); + + // Preserve email sent status. + Persistence.otpUsers.replace(dependentUser.id, dependentUser); + } + + /** + * Send 'accept dependent' email. + */ + private static boolean sendAcceptDependentEmail(OtpUser dependentUser, OtpUser relatedUser) { + Locale locale = I18nUtils.getOtpUserLocale(relatedUser); + + String acceptDependentLinkLabel = Message.ACCEPT_DEPENDENT_EMAIL_LINK_TEXT.get(locale); + String acceptDependentUrl = getAcceptDependentUrl(dependentUser); + + // A HashMap is needed instead of a Map for template data to be serialized to the template renderer. + Map templateData = new HashMap<>(Map.of( + "acceptDependentLinkAnchorLabel", acceptDependentLinkLabel, + "acceptDependentLinkLabelAndUrl", label(acceptDependentLinkLabel, acceptDependentUrl, locale), + "acceptDependentUrl", getAcceptDependentUrl(dependentUser), + "emailFooter", Message.ACCEPT_DEPENDENT_EMAIL_FOOTER.get(locale), + "emailGreeting", String.format("%s%s", dependentUser.email, Message.ACCEPT_DEPENDENT_EMAIL_GREETING.get(locale)), + // TODO: This is required in the `OtpUserContainer.ftl` template. Not sure what to link to so providing link back to settings. + "manageLinkUrl", String.format("%s%s", OTP_UI_URL, SETTINGS_PATH), + "manageLinkText", Message.ACCEPT_DEPENDENT_EMAIL_MANAGE.get(locale) + )); + + return NotificationUtils.sendEmail( + relatedUser, + Message.ACCEPT_DEPENDENT_EMAIL_SUBJECT.get(locale), + "AcceptDependentText.ftl", + "AcceptDependentHtml.ftl", + templateData + ); + } + + private static String getAcceptDependentUrl(OtpUser dependentUser) { + // TODO: Is OTP_UI_URL the correct base URL to user here? If not, what?! + return String.format("%s%s?userId=%s", OTP_UI_URL, ACCEPT_DEPENDENT_PATH, dependentUser.id); + } +} 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 081bbbc66..0ca1d2095 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java @@ -57,11 +57,11 @@ public class CheckMonitoredTrip implements Runnable { public static final int MAXIMUM_MONITORED_TRIP_ITINERARY_CHECKS = ConfigUtils.getConfigPropertyAsInt("MAXIMUM_MONITORED_TRIP_ITINERARY_CHECKS", 3); - private final String ACCOUNT_PATH = "/#/account"; + public static final String ACCOUNT_PATH = "/#/account"; private final String TRIPS_PATH = ACCOUNT_PATH + "/trips"; - private final String SETTINGS_PATH = ACCOUNT_PATH + "/settings"; + public static final String SETTINGS_PATH = ACCOUNT_PATH + "/settings"; public final MonitoredTrip trip; diff --git a/src/main/resources/Message.properties b/src/main/resources/Message.properties index 2f3a461b4..77a8b273b 100644 --- a/src/main/resources/Message.properties +++ b/src/main/resources/Message.properties @@ -1,3 +1,8 @@ +ACCEPT_DEPENDENT_EMAIL_FOOTER = You are receiving this email because you have been selected to be a trusted companion. +ACCEPT_DEPENDENT_EMAIL_GREETING = %s would like you to be their trusted companion. +ACCEPT_DEPENDENT_EMAIL_LINK_TEXT = Accept trusted companion +ACCEPT_DEPENDENT_EMAIL_SUBJECT = Trusted companion request +ACCEPT_DEPENDENT_EMAIL_MANAGE = Manage settings LABEL_AND_CONTENT = %s: %s SMS_STOP_NOTIFICATIONS = To stop receiving notifications, reply STOP. TRIP_EMAIL_SUBJECT = %s Notification diff --git a/src/main/resources/Message_fr.properties b/src/main/resources/Message_fr.properties index b64acce24..628bfd6d5 100644 --- a/src/main/resources/Message_fr.properties +++ b/src/main/resources/Message_fr.properties @@ -1,3 +1,8 @@ +ACCEPT_DEPENDENT_EMAIL_FOOTER = TODO +ACCEPT_DEPENDENT_EMAIL_GREETING = %s TODO. +ACCEPT_DEPENDENT_EMAIL_LINK_TEXT = TODO +ACCEPT_DEPENDENT_EMAIL_SUBJECT = TODO +ACCEPT_DEPENDENT_EMAIL_MANAGE = TODO LABEL_AND_CONTENT = %s\u00A0: %s SMS_STOP_NOTIFICATIONS = Pour arręter ces notifications, envoyez STOP. TRIP_EMAIL_SUBJECT = Notification pour %s diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 010aa1bd2..f6956e2f7 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -2887,6 +2887,8 @@ definitions: - "PENDING" - "CONFIRMED" - "INVALID" + acceptDependentEmailSent: + type: "boolean" FareComponent: type: "object" properties: diff --git a/src/main/resources/templates/AcceptDependentHtml.ftl b/src/main/resources/templates/AcceptDependentHtml.ftl new file mode 100644 index 000000000..583bd5549 --- /dev/null +++ b/src/main/resources/templates/AcceptDependentHtml.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}

+

${acceptDependentLinkAnchorLabel}

+
+ + +<@HtmlEmail/> \ No newline at end of file diff --git a/src/main/resources/templates/AcceptDependentText.ftl b/src/main/resources/templates/AcceptDependentText.ftl new file mode 100644 index 000000000..c87ed5a0c --- /dev/null +++ b/src/main/resources/templates/AcceptDependentText.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 deb1c6f27..70a1b3221 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java @@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; + import static org.opentripplanner.middleware.testutils.ApiTestUtils.createAndAssignAuth0User; import static org.opentripplanner.middleware.testutils.ApiTestUtils.getMockHeaders; import static org.opentripplanner.middleware.testutils.ApiTestUtils.makeGetRequest; @@ -35,6 +36,7 @@ import static org.opentripplanner.middleware.auth.Auth0Connection.restoreDefaultAuthDisabled; import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; import static org.opentripplanner.middleware.testutils.PersistenceTestUtils.deleteOtpUser; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.ACCEPT_DEPENDENT_PATH; public class OtpUserControllerTest extends OtpMiddlewareTestEnvironment { private static final String INITIAL_PHONE_NUMBER = "+15555550222"; // Fake US 555 number. @@ -46,7 +48,6 @@ public class OtpUserControllerTest extends OtpMiddlewareTestEnvironment { private static OtpUser relatedUserThree; private static OtpUser dependentUserThree; private static HashMap relatedUserHeaders; - public static final String ACCEPT_DEPENDENT_PATH = "api/secure/user/acceptdependent"; @BeforeAll public static void setUp() throws Exception {