diff --git a/src/main/Activity/UserActivity/NotifyIdPickupActivity.java b/src/main/Activity/UserActivity/NotifyIdPickupActivity.java new file mode 100644 index 00000000..42743635 --- /dev/null +++ b/src/main/Activity/UserActivity/NotifyIdPickupActivity.java @@ -0,0 +1,21 @@ +package Activity.UserActivity; + +import Activity.Activity; +import java.util.ArrayList; +import java.util.List; + +public class NotifyIdPickupActivity extends UserActivity { + @Override + public List construct() { + List a = new ArrayList<>(); + a.add(Activity.class.getSimpleName()); + a.add(UserActivity.class.getSimpleName()); + a.add(NotifyIdPickupActivity.class.getSimpleName()); + return a; + } + + public NotifyIdPickupActivity( + String workerUsername, String clientUsername, String idToPickup) { + super(workerUsername, clientUsername, idToPickup); + } +} diff --git a/src/main/Config/AppConfig.java b/src/main/Config/AppConfig.java index c6e83aac..3c8c4819 100644 --- a/src/main/Config/AppConfig.java +++ b/src/main/Config/AppConfig.java @@ -22,6 +22,8 @@ import Issue.IssueController; import Mail.FileBackfillController; import Mail.MailController; +import Notification.NotificationController; +import Notification.WindmillNotificationClient; import Organization.Organization; import Organization.OrganizationController; import PDF.PdfController; @@ -98,6 +100,8 @@ public static Javalin appFactory(DeploymentLevel deploymentLevel) { FileBackfillController backfillController = new FileBackfillController(db, fileDao, userDao); PdfControllerV2 pdfControllerV2 = new PdfControllerV2(fileDao, formDao, activityDao, userDao, encryptionController); + WindmillNotificationClient notificationClient = new WindmillNotificationClient(); + NotificationController notificationController = new NotificationController(activityDao, notificationClient); // try { do not recommend this block of code, this will delete and regenerate our encryption // key // System.out.println("generating keyset"); @@ -200,6 +204,9 @@ public static Javalin appFactory(DeploymentLevel deploymentLevel) { app.post("/get-all-activities", activityController.findMyActivities); app.post("/get-org-activities", activityController.findOrganizationActivities); + /* --------------- NOTIFICATION ROUTES ------------- */ + app.post("/notify-id-pickup", notificationController.notifyIdPickup); + /* --------------- FILE BACKFILL ROUTE ------------- */ // app.get("/backfill", backfillController.backfillSingleFile); diff --git a/src/main/Notification/NotificationController.java b/src/main/Notification/NotificationController.java new file mode 100644 index 00000000..6b96da0a --- /dev/null +++ b/src/main/Notification/NotificationController.java @@ -0,0 +1,59 @@ +package Notification; + +import Config.Message; +import Database.Activity.ActivityDao; +import Notification.Services.NotifyIdPickupService; +import User.UserMessage; +import io.javalin.http.Handler; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONObject; + +@Slf4j +public class NotificationController { + private ActivityDao activityDao; + private WindmillNotificationClient notificationClient; + + public NotificationController( + ActivityDao activityDao, WindmillNotificationClient notificationClient) { + this.activityDao = activityDao; + this.notificationClient = notificationClient; + } + + public Handler notifyIdPickup = + ctx -> { + JSONObject req = new JSONObject(ctx.body()); + + String sessionUsername = ctx.sessionAttribute("username"); + if (sessionUsername == null) { + ctx.result(UserMessage.SESSION_TOKEN_FAILURE.toResponseString()); + return; + } + + String workerUsername = req.getString("workerUsername"); + + if (!sessionUsername.equals(workerUsername)) { + ctx.result( + UserMessage.INVALID_PARAMETER + .withMessage("Worker username does not match authenticated session") + .toResponseString()); + return; + } + + String clientUsername = req.getString("clientUsername"); + String idToPickup = req.getString("idToPickup"); + String clientPhoneNumber = req.getString("clientPhoneNumber"); + String message = req.getString("message"); + + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + workerUsername, + clientUsername, + idToPickup, + clientPhoneNumber, + message); + Message responseMessage = service.executeAndGetResponse(); + ctx.result(responseMessage.toResponseString()); + }; +} diff --git a/src/main/Notification/Services/NotifyIdPickupService.java b/src/main/Notification/Services/NotifyIdPickupService.java new file mode 100644 index 00000000..8dfd8f54 --- /dev/null +++ b/src/main/Notification/Services/NotifyIdPickupService.java @@ -0,0 +1,73 @@ +package Notification.Services; + +import Activity.UserActivity.NotifyIdPickupActivity; +import Config.Message; +import Config.Service; +import Database.Activity.ActivityDao; +import Notification.WindmillNotificationClient; +import User.UserMessage; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class NotifyIdPickupService implements Service { + private final ActivityDao activityDao; + private final WindmillNotificationClient notificationClient; + private final String workerUsername; + private final String clientUsername; + private final String idToPickup; + private final String clientPhoneNumber; + private final String message; + + public NotifyIdPickupService( + ActivityDao activityDao, + WindmillNotificationClient notificationClient, + String workerUsername, + String clientUsername, + String idToPickup, + String clientPhoneNumber, + String message) { + this.activityDao = activityDao; + this.notificationClient = notificationClient; + this.workerUsername = workerUsername; + this.clientUsername = clientUsername; + this.idToPickup = idToPickup; + this.clientPhoneNumber = clientPhoneNumber; + this.message = message; + } + + @Override + public Message executeAndGetResponse() { + if (workerUsername == null || workerUsername.isBlank()) { + return UserMessage.INVALID_PARAMETER.withMessage("Worker username is required"); + } + if (clientUsername == null || clientUsername.isBlank()) { + return UserMessage.INVALID_PARAMETER.withMessage("Client username is required"); + } + if (idToPickup == null || idToPickup.isBlank()) { + return UserMessage.INVALID_PARAMETER.withMessage("ID to pickup is required"); + } + if (!notificationClient.isValidPhoneNumber(clientPhoneNumber)) { + return UserMessage.INVALID_PARAMETER.withMessage( + "Invalid phone number format. Expected +1XXXXXXXXXX"); + } + if (message == null || message.isBlank()) { + return UserMessage.INVALID_PARAMETER.withMessage("Message is required"); + } + + notificationClient.sendSms(clientPhoneNumber, message); + recordNotifyIdPickupActivity(); + + log.info( + "ID pickup notification sent from {} to {} for ID: {}", + workerUsername, + clientUsername, + idToPickup); + return UserMessage.SUCCESS; + } + + private void recordNotifyIdPickupActivity() { + NotifyIdPickupActivity activity = + new NotifyIdPickupActivity(workerUsername, clientUsername, idToPickup); + activityDao.save(activity); + } +} diff --git a/src/main/Notification/WindmillNotificationClient.java b/src/main/Notification/WindmillNotificationClient.java new file mode 100644 index 00000000..aeb9182f --- /dev/null +++ b/src/main/Notification/WindmillNotificationClient.java @@ -0,0 +1,112 @@ +package Notification; + +import okhttp3.*; +import com.google.gson.Gson; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + +@Slf4j +public class WindmillNotificationClient { + private final OkHttpClient client; + private final Gson gson; + private final String WINDMILL_URL; + private final String WINDMILL_TOKEN; + private final String TWILIO_PHONE_NUMBER; + private final Map twilioResource; + private final Pattern PHONE_PATTERN = Pattern.compile("\\+1\\d{10}"); // +1 followed by 10 digits + + public WindmillNotificationClient() { + this.client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + this.gson = new Gson(); + this.WINDMILL_URL = System.getenv("WINDMILL_URL"); + this.WINDMILL_TOKEN = System.getenv("WINDMILL_TOKEN"); + this.TWILIO_PHONE_NUMBER = System.getenv("TWILIO_PHONE_NUMBER"); + this.twilioResource = new HashMap<>(); + String TWILIO_ACCOUNT_SID = System.getenv("ACCOUNT_SID"); + String TWILIO_AUTH_TOKEN = System.getenv("AUTH_TOKEN_TWILIO"); + this.twilioResource.put("accountSid", TWILIO_ACCOUNT_SID); + this.twilioResource.put("token", TWILIO_AUTH_TOKEN); + } + + // Constructor for testing + public WindmillNotificationClient(String windmillUrl, String windmillToken, String twilioPhoneNumber, + String twilioAccountSid, String twilioAuthToken) { + this.client = new OkHttpClient(); + this.gson = new Gson(); + this.WINDMILL_URL = windmillUrl; + this.WINDMILL_TOKEN = windmillToken; + this.TWILIO_PHONE_NUMBER = twilioPhoneNumber; + this.twilioResource = new HashMap<>(); + this.twilioResource.put("accountSid", twilioAccountSid); + this.twilioResource.put("token", twilioAuthToken); + } + + public boolean isValidPhoneNumber(String phoneNumber) { + return phoneNumber != null && this.PHONE_PATTERN.matcher(phoneNumber).matches(); + } + + public void executeRequest(Request request, Callback callback) { + client.newCall(request).enqueue(callback); + } + + public void sendSms(String to, String message) { + if (!isValidPhoneNumber(to)) { + log.error("sendSms failed: invalid phone number provided: {}", to); + return; + } + if (message == null || message.isBlank()) { + log.error("sendSms failed: empty message provided: {}", message); + return; + } + + Map payload = Map.of( + "method", "sms", + "message", message, + "sms_config", Map.of( + "twilio_auth", twilioResource, + "to_phone_number", to, + "from_phone_number", this.TWILIO_PHONE_NUMBER + ), + "email_config", Map.of() + ); + + Request request = new Request.Builder() + .url(this.WINDMILL_URL) + .post(RequestBody.create(gson.toJson(payload), MediaType.parse("application/json"))) + .addHeader("Authorization", "Bearer " + this.WINDMILL_TOKEN) + .build(); + + log.info("Sending SMS to {} with message: {}", to, message); + + Callback callback = new Callback() { + public void onFailure(@NotNull Call call, @NotNull IOException e) { + log.error("sendSms failed: " + e.getMessage()); + } + + public void onResponse(@NotNull Call call, @NotNull Response response) { + try (response) { + if (response.isSuccessful()) { + log.info("sent SMS successfully. Status: {}", response.code()); + } else { + log.warn("SMS request completed but failed. Status: {}, Body: {}", + response.code(), response.body() != null ? response.body().string() : ""); + } + } catch (IOException e) { + log.error("caught error reading SMS response: " + e.getMessage()); + } + } + }; + executeRequest(request, callback); + } +} diff --git a/src/test/NotificationTest/NotifyIdPickupServiceTest.java b/src/test/NotificationTest/NotifyIdPickupServiceTest.java new file mode 100644 index 00000000..6972e49c --- /dev/null +++ b/src/test/NotificationTest/NotifyIdPickupServiceTest.java @@ -0,0 +1,132 @@ +package NotificationTest; + +import static org.junit.Assert.*; + +import Activity.Activity; +import Config.DeploymentLevel; +import Config.Message; +import Database.Activity.ActivityDao; +import Database.Activity.ActivityDaoFactory; +import Notification.Services.NotifyIdPickupService; +import Notification.WindmillNotificationClient; +import User.UserMessage; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class NotifyIdPickupServiceTest { + private ActivityDao activityDao; + private WindmillNotificationClient notificationClient; + + @Before + public void setUp() { + activityDao = ActivityDaoFactory.create(DeploymentLevel.IN_MEMORY); + notificationClient = + new WindmillNotificationClient( + "http://localhost:9999", "fake-token", "+10000000000", "fake-sid", "fake-auth"); + } + + @After + public void tearDown() { + activityDao.clear(); + } + + @Test + public void successfulNotificationSavesActivity() { + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + "worker1", + "client1", + "drivers-license", + "+12125551234", + "Your ID is ready for pickup!"); + Message result = service.executeAndGetResponse(); + + assertEquals(UserMessage.SUCCESS, result); + assertEquals(1, activityDao.size()); + + List activities = activityDao.getAllFromUser("worker1"); + assertEquals(1, activities.size()); + Activity activity = activities.get(0); + assertEquals("worker1", activity.getInvokerUsername()); + assertEquals("client1", activity.getTargetUsername()); + assertEquals("drivers-license", activity.getObjectName()); + assertEquals( + "NotifyIdPickupActivity", activity.getType().get(activity.getType().size() - 1)); + } + + @Test + public void invalidPhoneNumberReturnsError() { + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + "worker1", + "client1", + "drivers-license", + "not-a-phone", + "Your ID is ready!"); + Message result = service.executeAndGetResponse(); + + assertNotEquals(UserMessage.SUCCESS, result); + assertEquals(0, activityDao.size()); + } + + @Test + public void blankMessageReturnsError() { + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + "worker1", + "client1", + "drivers-license", + "+12125551234", + ""); + Message result = service.executeAndGetResponse(); + + assertNotEquals(UserMessage.SUCCESS, result); + assertEquals(0, activityDao.size()); + } + + @Test + public void nullWorkerUsernameReturnsError() { + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + null, + "client1", + "drivers-license", + "+12125551234", + "Your ID is ready!"); + Message result = service.executeAndGetResponse(); + + assertNotEquals(UserMessage.SUCCESS, result); + assertEquals(0, activityDao.size()); + } + + @Test + public void activityRecordedWithCorrectTypeChain() { + NotifyIdPickupService service = + new NotifyIdPickupService( + activityDao, + notificationClient, + "worker1", + "client1", + "state-id", + "+12125551234", + "Your state ID is ready"); + service.executeAndGetResponse(); + + Activity activity = activityDao.getAll().get(0); + List type = activity.getType(); + assertEquals(3, type.size()); + assertEquals("Activity", type.get(0)); + assertEquals("UserActivity", type.get(1)); + assertEquals("NotifyIdPickupActivity", type.get(2)); + } +} diff --git a/src/test/NotificationTest/WindmillNotificationClientTest.java b/src/test/NotificationTest/WindmillNotificationClientTest.java new file mode 100644 index 00000000..ee9ebb87 --- /dev/null +++ b/src/test/NotificationTest/WindmillNotificationClientTest.java @@ -0,0 +1,48 @@ +package NotificationTest; + +import Notification.WindmillNotificationClient; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Callback; +import okhttp3.Request; +import org.junit.Test; + +import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Slf4j +public class WindmillNotificationClientTest { + + WindmillNotificationClient client = new WindmillNotificationClient("http://localhost", + "test_windmill_token", "test_twilio_phone_number", + "test_twilio_account_sid", "test_twilio_auth_token"); + @Test + public void sendSMSSuccess() { + var testClient = new WindmillNotificationClient("http://localhost", + "test_windmill_token", "test_twilio_phone_number", + "test_twilio_account_sid", "test_twilio_auth_token") { + @Override + public void executeRequest(Request request, Callback callback) { + // Don't actually send, just verify the request looks right + assertNotNull(request); + assertEquals("POST", request.method()); + } + }; + + assertDoesNotThrow(() -> testClient.sendSms("+12025551234", "Test")); + } + + @Test + public void testValidPhoneNumbers() { + assertTrue(client.isValidPhoneNumber("+12025551234")); + assertTrue(client.isValidPhoneNumber("+19999999999")); + } + + @Test + public void testInvalidPhoneNumbers() { + assertFalse(client.isValidPhoneNumber("12025551234")); // missing + + assertFalse(client.isValidPhoneNumber("+44123456789")); // wrong country code + assertFalse(client.isValidPhoneNumber("+1202555123")); // too few digits + assertFalse(client.isValidPhoneNumber("+120255512345")); // too many digits + assertFalse(client.isValidPhoneNumber(null)); + } +}