diff --git a/build.gradle b/build.gradle index 31cb0821..7a4abdcb 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,7 @@ dependencies { implementation('org.springframework.boot:spring-boot-starter-quartz') implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.boot:spring-boot-starter-actuator') + implementation('org.springframework.boot:spring-boot-starter-mail') implementation group: "org.springframework.security", name: "spring-security-config", version: springSecurityVersion implementation('org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:' + springBootVersion) implementation('org.springframework.security.oauth:spring-security-oauth2:' + springOauth2Version) @@ -206,3 +207,7 @@ tasks.named("dependencyUpdates").configure { isNonStable(it.candidate.version) } } + +pmd { + sourceSets = [sourceSets.main] +} diff --git a/settings.gradle b/settings.gradle index 4e39c398..62ca6a70 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'appserver' \ No newline at end of file +rootProject.name = 'appserver' diff --git a/src/main/java/org/radarbase/appserver/converter/NotificationConverter.java b/src/main/java/org/radarbase/appserver/converter/NotificationConverter.java index 9629d36c..ad304766 100644 --- a/src/main/java/org/radarbase/appserver/converter/NotificationConverter.java +++ b/src/main/java/org/radarbase/appserver/converter/NotificationConverter.java @@ -66,6 +66,7 @@ public Notification dtoToEntity(FcmNotificationDto notificationDto) { .sound(notificationDto.getSound()) .subtitle(notificationDto.getSubtitle()) .tag(notificationDto.getTag()) + .emailEnabled(notificationDto.isEmailEnabled()) .build(); } diff --git a/src/main/java/org/radarbase/appserver/converter/UserConverter.java b/src/main/java/org/radarbase/appserver/converter/UserConverter.java index ede5f03d..7e1ca373 100644 --- a/src/main/java/org/radarbase/appserver/converter/UserConverter.java +++ b/src/main/java/org/radarbase/appserver/converter/UserConverter.java @@ -54,6 +54,7 @@ public User dtoToEntity(FcmUserDto fcmUserDto) { return new User() .setFcmToken(fcmUserDto.getFcmToken()) .setSubjectId(fcmUserDto.getSubjectId()) + .setEmailAddress(fcmUserDto.getEmailAddress()) .setUserMetrics(getValidUserMetrics(fcmUserDto)) .setEnrolmentDate(fcmUserDto.getEnrolmentDate()) .setTimezone(fcmUserDto.getTimezone()) diff --git a/src/main/java/org/radarbase/appserver/dto/fcm/FcmNotificationDto.java b/src/main/java/org/radarbase/appserver/dto/fcm/FcmNotificationDto.java index 741227bb..231889f0 100644 --- a/src/main/java/org/radarbase/appserver/dto/fcm/FcmNotificationDto.java +++ b/src/main/java/org/radarbase/appserver/dto/fcm/FcmNotificationDto.java @@ -109,6 +109,8 @@ public class FcmNotificationDto implements Serializable { private String clickAction; + private boolean emailEnabled; + private boolean mutableContent; @DateTimeFormat(iso = ISO.DATE_TIME) @@ -152,6 +154,7 @@ public FcmNotificationDto(Notification notificationEntity) { this.androidChannelId = notificationEntity.getAndroidChannelId(); this.tag = notificationEntity.getTag(); this.clickAction = notificationEntity.getClickAction(); + this.emailEnabled = notificationEntity.isEmailEnabled(); this.mutableContent = notificationEntity.isMutableContent(); } @@ -300,6 +303,11 @@ public FcmNotificationDto setClickAction(String clickAction) { return this; } + public FcmNotificationDto setEmailEnabled(boolean emailEnabled) { + this.emailEnabled = emailEnabled; + return this; + } + public FcmNotificationDto setMutableContent(boolean mutableContent) { this.mutableContent = mutableContent; return this; diff --git a/src/main/java/org/radarbase/appserver/dto/fcm/FcmUserDto.java b/src/main/java/org/radarbase/appserver/dto/fcm/FcmUserDto.java index ea601883..47e0e1b0 100644 --- a/src/main/java/org/radarbase/appserver/dto/fcm/FcmUserDto.java +++ b/src/main/java/org/radarbase/appserver/dto/fcm/FcmUserDto.java @@ -25,6 +25,8 @@ import java.io.Serializable; import java.time.Instant; import java.util.Map; + +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -56,6 +58,10 @@ public class FcmUserDto implements Serializable { // User ID to be used in org.radarcns.kafka.ObservationKey record keys private String subjectId; + @Email + // Email address of the user (optional, needed when sending notifications via email) + private String emailAddress; + // The most recent time when the app was opened private Instant lastOpened; @@ -129,6 +135,11 @@ public FcmUserDto setSubjectId(String subjectId) { return this; } + public FcmUserDto setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; + return this; + } + public FcmUserDto setLastOpened(Instant lastOpened) { this.lastOpened = lastOpened; return this; diff --git a/src/main/java/org/radarbase/appserver/dto/protocol/EmailNotificationProtocol.java b/src/main/java/org/radarbase/appserver/dto/protocol/EmailNotificationProtocol.java new file mode 100644 index 00000000..192d2dbc --- /dev/null +++ b/src/main/java/org/radarbase/appserver/dto/protocol/EmailNotificationProtocol.java @@ -0,0 +1,21 @@ +package org.radarbase.appserver.dto.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author yatharthranjan + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class EmailNotificationProtocol { + + @JsonProperty("enabled") + private boolean enabled = false; + +} diff --git a/src/main/java/org/radarbase/appserver/dto/protocol/NotificationProtocol.java b/src/main/java/org/radarbase/appserver/dto/protocol/NotificationProtocol.java index 8165c4f9..f623daa4 100644 --- a/src/main/java/org/radarbase/appserver/dto/protocol/NotificationProtocol.java +++ b/src/main/java/org/radarbase/appserver/dto/protocol/NotificationProtocol.java @@ -43,4 +43,8 @@ public class NotificationProtocol { @JsonProperty("text") private LanguageText body; + + @JsonProperty("email") + private EmailNotificationProtocol email; } + diff --git a/src/main/java/org/radarbase/appserver/entity/Notification.java b/src/main/java/org/radarbase/appserver/entity/Notification.java index 10b1f5cb..8977755a 100644 --- a/src/main/java/org/radarbase/appserver/entity/Notification.java +++ b/src/main/java/org/radarbase/appserver/entity/Notification.java @@ -46,7 +46,7 @@ * * @author yatharthranjan * @see Scheduled - * @see org.radarbase.appserver.service.scheduler.NotificationSchedulerService + * @see org.radarbase.appserver.service.scheduler.MessageSchedulerService */ @Table( name = "notifications", @@ -120,6 +120,9 @@ public class Notification extends Message { @Column(name = "click_action") private String clickAction; + @Column(name = "email_enabled") + private boolean emailEnabled = false; + @Nullable @ElementCollection(fetch = FetchType.EAGER) @MapKeyColumn(name = "additional_key", nullable = true) @@ -158,6 +161,7 @@ public static class NotificationBuilder { transient String androidChannelId; transient String tag; transient String clickAction; + transient boolean emailEnabled; transient Map additionalData; transient Task task; @@ -193,6 +197,7 @@ public NotificationBuilder(Notification notification) { this.androidChannelId = notification.getAndroidChannelId(); this.tag = notification.getTag(); this.clickAction = notification.getClickAction(); + this.emailEnabled = notification.isEmailEnabled(); this.additionalData = notification.getAdditionalData(); this.task = notification.getTask(); } @@ -343,6 +348,11 @@ public NotificationBuilder clickAction(String clickAction) { return this; } + public NotificationBuilder emailEnabled(boolean emailEnabled) { + this.emailEnabled = emailEnabled; + return this; + } + public NotificationBuilder additionalData(Map additionalData) { this.additionalData = additionalData; return this; @@ -386,6 +396,7 @@ public Notification build() { notification.setAndroidChannelId(this.androidChannelId); notification.setTag(this.tag); notification.setClickAction(this.clickAction); + notification.setEmailEnabled(this.emailEnabled); notification.setAdditionalData(this.additionalData); notification.setTask(this.task); diff --git a/src/main/java/org/radarbase/appserver/entity/User.java b/src/main/java/org/radarbase/appserver/entity/User.java index fc9c32f3..a14e2aa3 100644 --- a/src/main/java/org/radarbase/appserver/entity/User.java +++ b/src/main/java/org/radarbase/appserver/entity/User.java @@ -79,6 +79,9 @@ public class User extends AuditModel implements Serializable { @Column(name = "subject_id", nullable = false) private String subjectId; + @Column(name = "email") + private String emailAddress; + @NotNull @Column(name = "fcm_token", nullable = false, unique = true) private String fcmToken; @@ -118,6 +121,11 @@ public User setSubjectId(String subjectId) { return this; } + public User setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; + return this; + } + public User setFcmToken(String fcmToken) { this.fcmToken = fcmToken; return this; diff --git a/src/main/java/org/radarbase/appserver/exception/EmailMessageTransmitException.java b/src/main/java/org/radarbase/appserver/exception/EmailMessageTransmitException.java new file mode 100644 index 00000000..cb621fe2 --- /dev/null +++ b/src/main/java/org/radarbase/appserver/exception/EmailMessageTransmitException.java @@ -0,0 +1,13 @@ +package org.radarbase.appserver.exception; + +public class EmailMessageTransmitException extends MessageTransmitException { + + private static final long serialVersionUID = -1927189245766939L; + + public EmailMessageTransmitException(String message) { + super(message); + } + public EmailMessageTransmitException(String message, Throwable e) { + super(message, e); + } +} diff --git a/src/main/java/org/radarbase/appserver/exception/FcmMessageTransmitException.java b/src/main/java/org/radarbase/appserver/exception/FcmMessageTransmitException.java new file mode 100644 index 00000000..f085bedb --- /dev/null +++ b/src/main/java/org/radarbase/appserver/exception/FcmMessageTransmitException.java @@ -0,0 +1,13 @@ +package org.radarbase.appserver.exception; + +public class FcmMessageTransmitException extends MessageTransmitException { + + private static final long serialVersionUID = -923871442166939L; + + public FcmMessageTransmitException(String message) { + super(message); + } + public FcmMessageTransmitException(String message, Throwable e) { + super(message, e); + } +} diff --git a/src/main/java/org/radarbase/appserver/exception/MessageTransmitException.java b/src/main/java/org/radarbase/appserver/exception/MessageTransmitException.java new file mode 100644 index 00000000..e3b42f15 --- /dev/null +++ b/src/main/java/org/radarbase/appserver/exception/MessageTransmitException.java @@ -0,0 +1,12 @@ +package org.radarbase.appserver.exception; + +public class MessageTransmitException extends Exception { + private static final long serialVersionUID = -281834508766939L; + + public MessageTransmitException(String message) { + super(message); + } + public MessageTransmitException(String message, Throwable e) { + super(message, e); + } +} diff --git a/src/main/java/org/radarbase/appserver/service/FcmDataMessageService.java b/src/main/java/org/radarbase/appserver/service/FcmDataMessageService.java index 4022ece7..2c1b9474 100644 --- a/src/main/java/org/radarbase/appserver/service/FcmDataMessageService.java +++ b/src/main/java/org/radarbase/appserver/service/FcmDataMessageService.java @@ -21,13 +21,6 @@ package org.radarbase.appserver.service; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; import org.radarbase.appserver.converter.DataMessageConverter; import org.radarbase.appserver.dto.fcm.FcmDataMessageDto; import org.radarbase.appserver.dto.fcm.FcmDataMessages; @@ -43,12 +36,20 @@ import org.radarbase.appserver.repository.DataMessageRepository; import org.radarbase.appserver.repository.ProjectRepository; import org.radarbase.appserver.repository.UserRepository; -import org.radarbase.appserver.service.scheduler.DataMessageSchedulerService; +import org.radarbase.appserver.service.scheduler.MessageSchedulerService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + /** * {@link Service} for interacting with the {@link DataMessage} {@link jakarta.persistence.Entity} * using the {@link DataMessageRepository}. @@ -67,7 +68,7 @@ public class FcmDataMessageService implements DataMessageService { private final transient DataMessageRepository dataMessageRepository; private final transient UserRepository userRepository; private final transient ProjectRepository projectRepository; - private final transient DataMessageSchedulerService schedulerService; + private final transient MessageSchedulerService schedulerService; private final transient DataMessageConverter dataMessageConverter; private final transient ApplicationEventPublisher dataMessageStateEventPublisher; @@ -76,7 +77,7 @@ public FcmDataMessageService( DataMessageRepository dataMessageRepository, UserRepository userRepository, ProjectRepository projectRepository, - DataMessageSchedulerService schedulerService, + MessageSchedulerService schedulerService, DataMessageConverter dataMessageConverter, ApplicationEventPublisher eventPublisher) { this.dataMessageRepository = dataMessageRepository; diff --git a/src/main/java/org/radarbase/appserver/service/FcmNotificationService.java b/src/main/java/org/radarbase/appserver/service/FcmNotificationService.java index 559def74..42cf88da 100644 --- a/src/main/java/org/radarbase/appserver/service/FcmNotificationService.java +++ b/src/main/java/org/radarbase/appserver/service/FcmNotificationService.java @@ -42,7 +42,7 @@ import org.radarbase.appserver.repository.NotificationRepository; import org.radarbase.appserver.repository.ProjectRepository; import org.radarbase.appserver.repository.UserRepository; -import org.radarbase.appserver.service.scheduler.NotificationSchedulerService; +import org.radarbase.appserver.service.scheduler.MessageSchedulerService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -66,7 +66,7 @@ public class FcmNotificationService implements NotificationService { private final transient NotificationRepository notificationRepository; private final transient UserRepository userRepository; private final transient ProjectRepository projectRepository; - private final transient NotificationSchedulerService schedulerService; + private final transient MessageSchedulerService schedulerService; private final transient NotificationConverter notificationConverter; private final transient ApplicationEventPublisher notificationStateEventPublisher; @@ -75,7 +75,7 @@ public FcmNotificationService( NotificationRepository notificationRepository, UserRepository userRepository, ProjectRepository projectRepository, - NotificationSchedulerService schedulerService, + MessageSchedulerService schedulerService, NotificationConverter notificationConverter, ApplicationEventPublisher eventPublisher) { this.notificationRepository = notificationRepository; diff --git a/src/main/java/org/radarbase/appserver/service/UserService.java b/src/main/java/org/radarbase/appserver/service/UserService.java index d1e3771b..daa360f4 100644 --- a/src/main/java/org/radarbase/appserver/service/UserService.java +++ b/src/main/java/org/radarbase/appserver/service/UserService.java @@ -36,6 +36,7 @@ import org.radarbase.appserver.repository.ProjectRepository; import org.radarbase.appserver.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,6 +51,9 @@ @Slf4j public class UserService { + @Value("${radar.notification.email.enabled:false}") + private transient boolean sendEmailNotifications; + private final transient UserConverter userConverter; private final transient UserRepository userRepository; private final transient ProjectRepository projectRepository; @@ -152,6 +156,12 @@ public FcmUserDto saveUserInProject(FcmUserDto userDto) { + ". Please use Update endpoint if need to update the user"); } + if (sendEmailNotifications && (userDto.getEmailAddress() == null || userDto.getEmailAddress().isEmpty())) { + log.warn("No email address was provided for new subject '{}'. The option to send notifications via email " + + "('radar.notification.email.enabled') will not work for this subject. Consider to provide a valid email " + + "address for subject '{}'.", userDto.getSubjectId()); + } + User newUser = userConverter.dtoToEntity(userDto).setProject(project); // maintain a bi-directional relationship newUser.getUsermetrics().setUser(newUser); diff --git a/src/main/java/org/radarbase/appserver/service/questionnaire/notification/TaskNotificationGeneratorService.java b/src/main/java/org/radarbase/appserver/service/questionnaire/notification/TaskNotificationGeneratorService.java index 433d61d4..8ab75a7d 100644 --- a/src/main/java/org/radarbase/appserver/service/questionnaire/notification/TaskNotificationGeneratorService.java +++ b/src/main/java/org/radarbase/appserver/service/questionnaire/notification/TaskNotificationGeneratorService.java @@ -10,7 +10,8 @@ public class TaskNotificationGeneratorService { private transient int SECONDS_TO_MILLIS = 1000; - public Notification createNotification(Task task, Instant notificationTimestamp, String title, String body) { + public Notification createNotification(Task task, Instant notificationTimestamp, + String title, String body, boolean emailEnabled) { Notification.NotificationBuilder current = new Notification.NotificationBuilder(); current.scheduledTime(notificationTimestamp); @@ -22,6 +23,7 @@ public Notification createNotification(Task task, Instant notificationTimestamp, current.task(task); current.title(title); current.body(body); + current.emailEnabled(emailEnabled); return current.build(); } diff --git a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleNotificationHandler.java b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleNotificationHandler.java index 86b5908f..3281e41d 100644 --- a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleNotificationHandler.java +++ b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleNotificationHandler.java @@ -1,13 +1,13 @@ package org.radarbase.appserver.service.questionnaire.protocol; import org.radarbase.appserver.dto.protocol.Assessment; -import org.radarbase.appserver.dto.questionnaire.AssessmentSchedule; import org.radarbase.appserver.dto.protocol.NotificationProtocol; +import org.radarbase.appserver.dto.questionnaire.AssessmentSchedule; import org.radarbase.appserver.entity.Notification; import org.radarbase.appserver.entity.Task; import org.radarbase.appserver.entity.User; -import org.radarbase.appserver.service.questionnaire.notification.TaskNotificationGeneratorService; import org.radarbase.appserver.service.questionnaire.notification.NotificationType; +import org.radarbase.appserver.service.questionnaire.notification.TaskNotificationGeneratorService; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -17,32 +17,34 @@ public class SimpleNotificationHandler implements ProtocolHandler { private transient TaskNotificationGeneratorService taskNotificationGeneratorService = new TaskNotificationGeneratorService(); - public SimpleNotificationHandler() { } + public SimpleNotificationHandler() { + } @Override public AssessmentSchedule handle(AssessmentSchedule assessmentSchedule, Assessment assessment, User user) { NotificationProtocol protocol = assessment.getProtocol().getNotification(); String title = this.taskNotificationGeneratorService.getTitleText(user.getLanguage(), protocol.getTitle(), NotificationType.NOW); String body = this.taskNotificationGeneratorService.getBodyText(user.getLanguage(), protocol.getBody(), NotificationType.NOW, assessment.getEstimatedCompletionTime()); - List notifications = generateNotifications(assessmentSchedule.getTasks(), user, title, body); + List notifications = generateNotifications(assessmentSchedule.getTasks(), user, title, body, protocol.getEmail().isEnabled()); assessmentSchedule.setNotifications(notifications); return assessmentSchedule; } - public List generateNotifications(List tasks, User user, String title, String body) { + public List generateNotifications(List tasks, User user, + String title, String body, boolean emailEnabled) { return tasks.parallelStream() .map(task -> { - Notification notification = this.taskNotificationGeneratorService - .createNotification( - task, - task.getTimestamp().toInstant(), - title, - body); - notification.setUser(user); - return notification; + Notification notification = this.taskNotificationGeneratorService + .createNotification( + task, + task.getTimestamp().toInstant(), + title, + body, + emailEnabled); + notification.setUser(user); + return notification; }) - .filter(notification-> (Instant.now().isBefore(notification.getScheduledTime() + .filter(notification -> (Instant.now().isBefore(notification.getScheduledTime() .plus(notification.getTtlSeconds(), ChronoUnit.SECONDS)))).collect(Collectors.toList()); } - } \ No newline at end of file diff --git a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleReminderHandler.java b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleReminderHandler.java index c2fe0ead..b57a8d8f 100644 --- a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleReminderHandler.java +++ b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/SimpleReminderHandler.java @@ -1,14 +1,14 @@ package org.radarbase.appserver.service.questionnaire.protocol; import org.radarbase.appserver.dto.protocol.Assessment; +import org.radarbase.appserver.dto.protocol.NotificationProtocol; import org.radarbase.appserver.dto.protocol.ReminderTimePeriod; import org.radarbase.appserver.dto.questionnaire.AssessmentSchedule; -import org.radarbase.appserver.dto.protocol.NotificationProtocol; import org.radarbase.appserver.entity.Notification; import org.radarbase.appserver.entity.Task; import org.radarbase.appserver.entity.User; -import org.radarbase.appserver.service.questionnaire.notification.TaskNotificationGeneratorService; import org.radarbase.appserver.service.questionnaire.notification.NotificationType; +import org.radarbase.appserver.service.questionnaire.notification.TaskNotificationGeneratorService; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -21,7 +21,8 @@ public class SimpleReminderHandler implements ProtocolHandler { private transient TaskNotificationGeneratorService taskNotificationGeneratorService = new TaskNotificationGeneratorService(); private transient TimeCalculatorService timeCalculatorService = new TimeCalculatorService(); - public SimpleReminderHandler() { } + public SimpleReminderHandler() { + } @Override public AssessmentSchedule handle(AssessmentSchedule assessmentSchedule, Assessment assessment, User user) { @@ -29,24 +30,27 @@ public AssessmentSchedule handle(AssessmentSchedule assessmentSchedule, Assessme NotificationProtocol protocol = assessment.getProtocol().getNotification(); String title = this.taskNotificationGeneratorService.getTitleText(user.getLanguage(), protocol.getTitle(), NotificationType.REMINDER); String body = this.taskNotificationGeneratorService.getBodyText(user.getLanguage(), protocol.getBody(), NotificationType.REMINDER, assessment.getEstimatedCompletionTime()); - List notifications = generateReminders(assessmentSchedule.getTasks(), assessment, timezone, user, title, body); + List notifications = generateReminders(assessmentSchedule.getTasks(), assessment, timezone, user, + title, body, protocol.getEmail().isEnabled()); assessmentSchedule.setReminders(notifications); return assessmentSchedule; } - public List generateReminders(List tasks, Assessment assessment, TimeZone timezone, User user, String title, String body) { + public List generateReminders(List tasks, Assessment assessment, TimeZone timezone, + User user, String title, String body, boolean emailEnabled) { return tasks.parallelStream() .flatMap(task -> { - ReminderTimePeriod reminders = assessment.getProtocol().getReminders(); - return IntStream.range(0, reminders.getRepeat()).mapToObj(i -> { - Instant timestamp = timeCalculatorService.advanceRepeat(task.getTimestamp().toInstant(), reminders, timezone); - Notification notification = this.taskNotificationGeneratorService.createNotification(task, timestamp, title, body); - notification.setUser(user); - return notification; - }); + ReminderTimePeriod reminders = assessment.getProtocol().getReminders(); + return IntStream.range(0, reminders.getRepeat()).mapToObj(i -> { + Instant timestamp = timeCalculatorService.advanceRepeat(task.getTimestamp().toInstant(), reminders, timezone); + Notification notification = this.taskNotificationGeneratorService.createNotification( + task, timestamp, title, body, emailEnabled); + notification.setUser(user); + return notification; + }); } ) - .filter(notification-> (Instant.now().isBefore(notification.getScheduledTime() + .filter(notification -> (Instant.now().isBefore(notification.getScheduledTime() .plus(notification.getTtlSeconds(), ChronoUnit.SECONDS)))).collect(Collectors.toList()); } diff --git a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/factory/NotificationHandlerFactory.java b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/factory/NotificationHandlerFactory.java index 85b2c6f3..7c883495 100644 --- a/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/factory/NotificationHandlerFactory.java +++ b/src/main/java/org/radarbase/appserver/service/questionnaire/protocol/factory/NotificationHandlerFactory.java @@ -22,7 +22,6 @@ package org.radarbase.appserver.service.questionnaire.protocol.factory; -import org.radarbase.appserver.service.FcmNotificationService; import org.radarbase.appserver.service.questionnaire.protocol.ProtocolHandler; import org.radarbase.appserver.service.questionnaire.protocol.DisabledNotificationHandler; import org.radarbase.appserver.service.questionnaire.protocol.SimpleNotificationHandler; diff --git a/src/main/java/org/radarbase/appserver/service/scheduler/DataMessageSchedulerService.java b/src/main/java/org/radarbase/appserver/service/scheduler/DataMessageSchedulerService.java deleted file mode 100644 index 87007071..00000000 --- a/src/main/java/org/radarbase/appserver/service/scheduler/DataMessageSchedulerService.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * - * * - * * * Copyright 2018 King's College London - * * * - * * * Licensed under the Apache License, Version 2.0 (the "License"); - * * * you may not use this file except in compliance with the License. - * * * You may obtain a copy of the License at - * * * - * * * http://www.apache.org/licenses/LICENSE-2.0 - * * * - * * * Unless required by applicable law or agreed to in writing, software - * * * distributed under the License is distributed on an "AS IS" BASIS, - * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * * * See the License for the specific language governing permissions and - * * * limitations under the License. - * * * - * * - * - */ - -package org.radarbase.appserver.service.scheduler; - -import java.util.Objects; -import lombok.extern.slf4j.Slf4j; -import org.radarbase.appserver.entity.DataMessage; -import org.radarbase.appserver.service.scheduler.quartz.SchedulerService; -import org.radarbase.fcm.downstream.FcmSender; -import org.radarbase.fcm.model.FcmDataMessage; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; - -import static org.radarbase.appserver.service.scheduler.NotificationSchedulerService.DEFAULT_TIME_TO_LIVE; - -/** - * {@link Service} for scheduling Data Messages to be sent through FCM at the {@link - * org.radarbase.appserver.entity.Scheduled} time. It also provided functions for updating/ deleting - * already scheduled Data Messsage Jobs. - * - * @author yatharthranjan - */ -@Service -@Slf4j -@SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public class DataMessageSchedulerService extends MessageSchedulerService { - - public DataMessageSchedulerService( - @Autowired @Qualifier("fcmSenderProps") FcmSender fcmSender, - @Autowired SchedulerService schedulerService) { - super(fcmSender, schedulerService); - } - - private static FcmDataMessage createMessageFromDataMessage(DataMessage dataMessage) { - - String to = - Objects.requireNonNullElseGet( - dataMessage.getFcmTopic(), dataMessage.getUser()::getFcmToken); - return FcmDataMessage.builder() - .to(to) - .condition(dataMessage.getFcmCondition()) - .priority(dataMessage.getPriority()) - .mutableContent(dataMessage.isMutableContent()) - .deliveryReceiptRequested(IS_DELIVERY_RECEIPT_REQUESTED) - .messageId(String.valueOf(dataMessage.getFcmMessageId())) - .timeToLive(Objects.requireNonNullElse(dataMessage.getTtlSeconds(), DEFAULT_TIME_TO_LIVE)) - .data(dataMessage.getDataMap()) - .build(); - } - - public void send(DataMessage dataMessage) throws Exception { - fcmSender.send(createMessageFromDataMessage(dataMessage)); - } -} diff --git a/src/main/java/org/radarbase/appserver/service/scheduler/MessageSchedulerService.java b/src/main/java/org/radarbase/appserver/service/scheduler/MessageSchedulerService.java index d16aea4e..f74241f2 100644 --- a/src/main/java/org/radarbase/appserver/service/scheduler/MessageSchedulerService.java +++ b/src/main/java/org/radarbase/appserver/service/scheduler/MessageSchedulerService.java @@ -53,12 +53,10 @@ @Service @Slf4j @SuppressWarnings("PMD.DataflowAnomalyAnalysis") -public abstract class MessageSchedulerService { +public class MessageSchedulerService { // TODO add a schedule cache to cache incoming requests - protected static final QuartzNamingStrategy NAMING_STRATEGY = new SimpleQuartzNamingStrategy(); - protected static final boolean IS_DELIVERY_RECEIPT_REQUESTED = true; protected final transient FcmSender fcmSender; protected final transient SchedulerService schedulerService; @@ -69,8 +67,6 @@ public MessageSchedulerService( this.schedulerService = schedulerService; } - public abstract void send(T message) throws Exception; - public static SimpleTriggerFactoryBean getTriggerForMessage( Message message, JobDetail jobDetail) { SimpleTriggerFactoryBean triggerFactoryBean = new SimpleTriggerFactoryBean(); @@ -104,12 +100,6 @@ public static JobDetailFactoryBean getJobDetailForMessage(Message message, Messa return jobDetailFactory; } - protected static void putIfNotNull(Map map, String key, Object value) { - if (value != null) { - map.put(key, value); - } - } - public void schedule(T message) { log.info("Message = {}", message); diff --git a/src/main/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerService.java b/src/main/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerService.java deleted file mode 100644 index dce35ace..00000000 --- a/src/main/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerService.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * - * * - * * * Copyright 2018 King's College London - * * * - * * * Licensed under the Apache License, Version 2.0 (the "License"); - * * * you may not use this file except in compliance with the License. - * * * You may obtain a copy of the License at - * * * - * * * http://www.apache.org/licenses/LICENSE-2.0 - * * * - * * * Unless required by applicable law or agreed to in writing, software - * * * distributed under the License is distributed on an "AS IS" BASIS, - * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * * * See the License for the specific language governing permissions and - * * * limitations under the License. - * * * - * * - * - */ - -package org.radarbase.appserver.service.scheduler; - -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import lombok.extern.slf4j.Slf4j; -import org.radarbase.appserver.entity.Notification; -import org.radarbase.appserver.service.scheduler.quartz.SchedulerService; -import org.radarbase.fcm.downstream.FcmSender; -import org.radarbase.fcm.model.FcmNotificationMessage; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; - -/** - * {@link Service} for scheduling Notifications to be sent through FCM at the {@link - * org.radarbase.appserver.entity.Scheduled} time. It also provided functions for updating/ deleting - * already scheduled Notification Jobs. - * - * @author yatharthranjan - */ -@Service -@Slf4j -public class NotificationSchedulerService extends MessageSchedulerService { - static final int DEFAULT_TIME_TO_LIVE = 2_419_200; // 4 weeks - - public NotificationSchedulerService( - @Autowired @Qualifier("fcmSenderProps") FcmSender fcmSender, - @Autowired SchedulerService schedulerService) { - super(fcmSender, schedulerService); - } - - private static Map getNotificationMap(Notification notification) { - Map notificationMap = new HashMap<>(); - notificationMap.put("body", notification.getBody()); - notificationMap.put("title", notification.getTitle()); - notificationMap.put("sound", "default"); - - putIfNotNull(notificationMap, "sound", notification.getSound()); - putIfNotNull(notificationMap, "badge", notification.getBadge()); - putIfNotNull(notificationMap, "click_action", notification.getClickAction()); - putIfNotNull(notificationMap, "subtitle", notification.getSubtitle()); - putIfNotNull(notificationMap, "body_loc_key", notification.getBodyLocKey()); - putIfNotNull(notificationMap, "body_loc_args", notification.getBodyLocArgs()); - putIfNotNull(notificationMap, "title_loc_key", notification.getTitleLocKey()); - putIfNotNull(notificationMap, "title_loc_args", notification.getTitleLocArgs()); - putIfNotNull(notificationMap, "android_channel_id", notification.getAndroidChannelId()); - putIfNotNull(notificationMap, "icon", notification.getIcon()); - putIfNotNull(notificationMap, "tag", notification.getTag()); - putIfNotNull(notificationMap, "color", notification.getColor()); - - return notificationMap; - } - - private static FcmNotificationMessage createMessageFromNotification(Notification notification) { - - String to = - Objects.requireNonNullElseGet( - notification.getFcmTopic(), notification.getUser()::getFcmToken); - return FcmNotificationMessage.builder() - .to(to) - .condition(notification.getFcmCondition()) - .priority(notification.getPriority()) - .mutableContent(notification.isMutableContent()) - .deliveryReceiptRequested(IS_DELIVERY_RECEIPT_REQUESTED) - .messageId(String.valueOf(notification.getFcmMessageId())) - .timeToLive(Objects.requireNonNullElse(notification.getTtlSeconds(), DEFAULT_TIME_TO_LIVE)) - .notification(getNotificationMap(notification)) - .data(notification.getAdditionalData()) - .build(); - } - - - public void send(Notification notification) throws Exception { - fcmSender.send(createMessageFromNotification(notification)); - } -} diff --git a/src/main/java/org/radarbase/appserver/service/scheduler/quartz/MessageJob.java b/src/main/java/org/radarbase/appserver/service/scheduler/quartz/MessageJob.java index dab67990..0b7fe77c 100644 --- a/src/main/java/org/radarbase/appserver/service/scheduler/quartz/MessageJob.java +++ b/src/main/java/org/radarbase/appserver/service/scheduler/quartz/MessageJob.java @@ -21,56 +21,54 @@ package org.radarbase.appserver.service.scheduler.quartz; -import com.google.firebase.ErrorCode; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.MessagingErrorCode; import lombok.extern.slf4j.Slf4j; import org.quartz.Job; import org.quartz.JobDataMap; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; -import org.radarbase.appserver.dto.fcm.FcmUserDto; import org.radarbase.appserver.entity.DataMessage; -import org.radarbase.appserver.entity.Message; import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.exception.FcmMessageTransmitException; +import org.radarbase.appserver.exception.MessageTransmitException; import org.radarbase.appserver.service.FcmDataMessageService; import org.radarbase.appserver.service.FcmNotificationService; import org.radarbase.appserver.service.MessageType; -import org.radarbase.appserver.service.UserService; -import org.radarbase.appserver.service.scheduler.DataMessageSchedulerService; -import org.radarbase.appserver.service.scheduler.NotificationSchedulerService; +import org.radarbase.appserver.service.transmitter.DataMessageTransmitter; +import org.radarbase.appserver.service.transmitter.EmailNotificationTransmitter; +import org.radarbase.appserver.service.transmitter.FcmTransmitter; +import org.radarbase.appserver.service.transmitter.NotificationTransmitter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; /** - * A {@link Job} that sends an FCM message to the device when executed. + * A {@link Job} that sends messages to the device or email when executed. * * @author yatharthranjan - * @see NotificationSchedulerService - * @see SchedulerServiceImpl + * @see FcmTransmitter + * @see EmailNotificationTransmitter */ @Slf4j public class MessageJob implements Job { - private final transient NotificationSchedulerService notificationSchedulerService; + private final transient List notificationTransmitters; - private final transient DataMessageSchedulerService dataMessageSchedulerService; + private final transient List dataMessageTransmitters; private final transient FcmNotificationService notificationService; private final transient FcmDataMessageService dataMessageService; - private final transient UserService userService; - public MessageJob( - NotificationSchedulerService notificationSchedulerService, - DataMessageSchedulerService dataMessageSchedulerService, + List notificationTransmitters, + List dataMessageTransmitters, FcmNotificationService notificationService, - FcmDataMessageService dataMessageService, - UserService userService) { - this.notificationSchedulerService = notificationSchedulerService; - this.dataMessageSchedulerService = dataMessageSchedulerService; + FcmDataMessageService dataMessageService) { + this.notificationTransmitters = notificationTransmitters; + this.dataMessageTransmitters = dataMessageTransmitters; this.notificationService = notificationService; this.dataMessageService = dataMessageService; - this.userService = userService; } /** @@ -88,90 +86,53 @@ public MessageJob( @Override @SuppressWarnings("PMD.DataflowAnomalyAnalysis") public void execute(JobExecutionContext context) throws JobExecutionException { - Message message = null; + JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + MessageType type = MessageType.valueOf(jobDataMap.getString("messageType")); + String projectId = jobDataMap.getString("projectId"); + String subjectId = jobDataMap.getString("subjectId"); + Long messageId = jobDataMap.getLong("messageId"); + List exceptions = new ArrayList<>(); try { - JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); - MessageType type = MessageType.valueOf(jobDataMap.getString("messageType")); - String projectId = jobDataMap.getString("projectId"); - String subjectId = jobDataMap.getString("subjectId"); - Long messageId = jobDataMap.getLong("messageId"); switch (type) { case NOTIFICATION: Notification notification = notificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId( projectId, subjectId, messageId); - message = notification; - notificationSchedulerService.send(notification); + notificationTransmitters.forEach(t -> { + try { + t.send(notification); + } catch (MessageTransmitException e) { + exceptions.add(e); + } + }); break; case DATA: DataMessage dataMessage = dataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId( projectId, subjectId, messageId); - message = dataMessage; - dataMessageSchedulerService.send(dataMessage); + dataMessageTransmitters.forEach(t -> { + try { + t.send(dataMessage); + } catch (MessageTransmitException e) { + exceptions.add(e); + } + }); break; default: break; } - } catch (FirebaseMessagingException exc) { - log.error("Error occurred when sending downstream message.", exc); - // TODO: update the data message status using event - if (message != null) { - handleErrorCode(exc.getErrorCode(), message); - handleFCMErrorCode(exc.getMessagingErrorCode(), message); - } } catch (Exception e) { - throw new JobExecutionException(e); + log.error("Could not transmit a message", e); + throw new JobExecutionException("Could not transmit a message", e); } - } - - protected void handleErrorCode(ErrorCode errorCode, Message message) { - // More info on ErrorCode: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode - switch (errorCode) { - case INVALID_ARGUMENT: - case INTERNAL: - case ABORTED: - case CONFLICT: - case CANCELLED: - case DATA_LOSS: - case NOT_FOUND: - case OUT_OF_RANGE: - case ALREADY_EXISTS: - case DEADLINE_EXCEEDED: - case PERMISSION_DENIED: - case RESOURCE_EXHAUSTED: - case FAILED_PRECONDITION: - case UNAUTHENTICATED: - case UNKNOWN: - break; - case UNAVAILABLE: - // TODO: Could schedule for retry. - log.warn("The FCM service is unavailable."); - break; - } - } - protected void handleFCMErrorCode(MessagingErrorCode errorCode, Message message) { - switch (errorCode) { - case INTERNAL: - case QUOTA_EXCEEDED: - case INVALID_ARGUMENT: - case SENDER_ID_MISMATCH: - case THIRD_PARTY_AUTH_ERROR: - break; - case UNAVAILABLE: - // TODO: Could schedule for retry. - log.warn("The FCM service is unavailable."); - break; - case UNREGISTERED: - FcmUserDto userDto = new FcmUserDto(message.getUser()); - log.warn("The Device for user {} was unregistered.", userDto.getSubjectId()); - notificationService.removeNotificationsForUser( - userDto.getProjectId(), userDto.getSubjectId()); - dataMessageService.removeDataMessagesForUser( - userDto.getProjectId(), userDto.getSubjectId()); - userService.checkFcmTokenExistsAndReplace(userDto); - break; + // Here handle the exceptions that occurred while transmitting the message via the + // transmitters. At present, only the FcmTransmitter affects the job execution state. + Optional fcmException = exceptions.stream() + .filter(e -> e instanceof FcmMessageTransmitException) + .findFirst(); + if (fcmException.isPresent()) { + throw new JobExecutionException("Could not transmit a message", fcmException.get()); } } } diff --git a/src/main/java/org/radarbase/appserver/service/transmitter/DataMessageTransmitter.java b/src/main/java/org/radarbase/appserver/service/transmitter/DataMessageTransmitter.java new file mode 100644 index 00000000..d48d6b77 --- /dev/null +++ b/src/main/java/org/radarbase/appserver/service/transmitter/DataMessageTransmitter.java @@ -0,0 +1,8 @@ +package org.radarbase.appserver.service.transmitter; + +import org.radarbase.appserver.entity.DataMessage; +import org.radarbase.appserver.exception.MessageTransmitException; + +public interface DataMessageTransmitter { + void send(DataMessage dataMessage) throws MessageTransmitException; +} diff --git a/src/main/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitter.java b/src/main/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitter.java new file mode 100644 index 00000000..fd7b93d4 --- /dev/null +++ b/src/main/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitter.java @@ -0,0 +1,54 @@ +package org.radarbase.appserver.service.transmitter; + +import lombok.extern.slf4j.Slf4j; +import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.exception.EmailMessageTransmitException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(value = "radar.notification.email.enabled", havingValue = "true") +public class EmailNotificationTransmitter implements NotificationTransmitter { + + private final transient JavaMailSender emailSender; + + @Value("${radar.notification.email.from}") + private transient String from; + + public EmailNotificationTransmitter( + @Autowired JavaMailSender emailSender + ) { + this.emailSender = emailSender; + } + + @Override + public void send(Notification notification) throws EmailMessageTransmitException { + if (notification.isEmailEnabled()) { + try { + if (notification.getUser().getEmailAddress() == null || notification.getUser().getEmailAddress().isBlank()) { + log.warn("Could not transmit a notification via email because subject {} has no email address.", + notification.getUser().getSubjectId()); + return; + } + emailSender.send(createEmailFromNotification(notification)); + } catch (Exception e) { + log.error("Could not transmit a notification via email", e); + throw new EmailMessageTransmitException("Could not transmit a notification via email", e); + } + } + } + + private SimpleMailMessage createEmailFromNotification(Notification notification) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(notification.getUser().getEmailAddress()); + message.setSubject(notification.getTitle()); + message.setText(notification.getBody()); + return message; + } +} diff --git a/src/main/java/org/radarbase/appserver/service/transmitter/FcmTransmitter.java b/src/main/java/org/radarbase/appserver/service/transmitter/FcmTransmitter.java new file mode 100644 index 00000000..be9ed5f9 --- /dev/null +++ b/src/main/java/org/radarbase/appserver/service/transmitter/FcmTransmitter.java @@ -0,0 +1,196 @@ +package org.radarbase.appserver.service.transmitter; + +import com.google.firebase.ErrorCode; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.radarbase.appserver.dto.fcm.FcmUserDto; +import org.radarbase.appserver.entity.DataMessage; +import org.radarbase.appserver.entity.Message; +import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.exception.FcmMessageTransmitException; +import org.radarbase.appserver.exception.MessageTransmitException; +import org.radarbase.appserver.service.FcmDataMessageService; +import org.radarbase.appserver.service.FcmNotificationService; +import org.radarbase.appserver.service.UserService; +import org.radarbase.fcm.downstream.FcmSender; +import org.radarbase.fcm.model.FcmDataMessage; +import org.radarbase.fcm.model.FcmNotificationMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@Component +@SuppressWarnings("PMD.DataflowAnomalyAnalysis") +public class FcmTransmitter implements NotificationTransmitter, DataMessageTransmitter { + + protected static final boolean IS_DELIVERY_RECEIPT_REQUESTED = true; + static final int DEFAULT_TIME_TO_LIVE = 2_419_200; // 4 weeks + + protected final transient FcmSender fcmSender; + private final transient FcmNotificationService notificationService; + private final transient FcmDataMessageService dataMessageService; + private final transient UserService userService; + + public FcmTransmitter( + @Autowired @Qualifier("fcmSenderProps") FcmSender fcmSender, + @Autowired FcmNotificationService notificationService, + @Autowired FcmDataMessageService dataMessageService, + @Autowired UserService userService + ) { + this.fcmSender = fcmSender; + this.notificationService = notificationService; + this.dataMessageService = dataMessageService; + this.userService = userService; + } + + @Override + @SneakyThrows + public void send(Notification notification) { + try { + fcmSender.send(createMessageFromNotification(notification)); + } catch (FirebaseMessagingException exc) { + handleFcmException(exc, notification); + } catch (Exception exc) { + throw new FcmMessageTransmitException("Could not transmit a notification through Fcm", exc); + } + } + + @Override + @SneakyThrows + public void send(DataMessage dataMessage) { + try { + fcmSender.send(createMessageFromDataMessage(dataMessage)); + } catch (FirebaseMessagingException exc) { + handleFcmException(exc, dataMessage); + } catch (Exception exc) { + throw new FcmMessageTransmitException("Could not transmit a data message through Fcm", exc); + } + } + + private void handleFcmException(FirebaseMessagingException exc, Message message) { + log.error("Error occurred when sending downstream message.", exc); + if (message != null) { + handleErrorCode(exc.getErrorCode(), message); + handleFCMErrorCode(exc.getMessagingErrorCode(), message); + } + } + + private static FcmNotificationMessage createMessageFromNotification(Notification notification) { + String to = + Objects.requireNonNullElseGet( + notification.getFcmTopic(), notification.getUser()::getFcmToken); + return FcmNotificationMessage.builder() + .to(to) + .condition(notification.getFcmCondition()) + .priority(notification.getPriority()) + .mutableContent(notification.isMutableContent()) + .deliveryReceiptRequested(IS_DELIVERY_RECEIPT_REQUESTED) + .messageId(String.valueOf(notification.getFcmMessageId())) + .timeToLive(Objects.requireNonNullElse(notification.getTtlSeconds(), DEFAULT_TIME_TO_LIVE)) + .notification(getNotificationMap(notification)) + .data(notification.getAdditionalData()) + .build(); + } + + private static FcmDataMessage createMessageFromDataMessage(DataMessage dataMessage) { + String to = + Objects.requireNonNullElseGet( + dataMessage.getFcmTopic(), dataMessage.getUser()::getFcmToken); + return FcmDataMessage.builder() + .to(to) + .condition(dataMessage.getFcmCondition()) + .priority(dataMessage.getPriority()) + .mutableContent(dataMessage.isMutableContent()) + .deliveryReceiptRequested(IS_DELIVERY_RECEIPT_REQUESTED) + .messageId(String.valueOf(dataMessage.getFcmMessageId())) + .timeToLive(Objects.requireNonNullElse(dataMessage.getTtlSeconds(), DEFAULT_TIME_TO_LIVE)) + .data(dataMessage.getDataMap()) + .build(); + } + + private static Map getNotificationMap(Notification notification) { + Map notificationMap = new HashMap<>(); + notificationMap.put("body", notification.getBody()); + notificationMap.put("title", notification.getTitle()); + notificationMap.put("sound", "default"); + + putIfNotNull(notificationMap, "sound", notification.getSound()); + putIfNotNull(notificationMap, "badge", notification.getBadge()); + putIfNotNull(notificationMap, "click_action", notification.getClickAction()); + putIfNotNull(notificationMap, "subtitle", notification.getSubtitle()); + putIfNotNull(notificationMap, "body_loc_key", notification.getBodyLocKey()); + putIfNotNull(notificationMap, "body_loc_args", notification.getBodyLocArgs()); + putIfNotNull(notificationMap, "title_loc_key", notification.getTitleLocKey()); + putIfNotNull(notificationMap, "title_loc_args", notification.getTitleLocArgs()); + putIfNotNull(notificationMap, "android_channel_id", notification.getAndroidChannelId()); + putIfNotNull(notificationMap, "icon", notification.getIcon()); + putIfNotNull(notificationMap, "tag", notification.getTag()); + putIfNotNull(notificationMap, "color", notification.getColor()); + + return notificationMap; + } + + protected static void putIfNotNull(Map map, String key, Object value) { + if (value != null) { + map.put(key, value); + } + } + + protected void handleErrorCode(ErrorCode errorCode, Message message) { + // More info on ErrorCode: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + switch (errorCode) { + case INVALID_ARGUMENT: + case INTERNAL: + case ABORTED: + case CONFLICT: + case CANCELLED: + case DATA_LOSS: + case NOT_FOUND: + case OUT_OF_RANGE: + case ALREADY_EXISTS: + case DEADLINE_EXCEEDED: + case PERMISSION_DENIED: + case RESOURCE_EXHAUSTED: + case FAILED_PRECONDITION: + case UNAUTHENTICATED: + case UNKNOWN: + break; + case UNAVAILABLE: + // TODO: Could schedule for retry. + log.warn("The FCM service is unavailable."); + break; + } + } + + protected void handleFCMErrorCode(MessagingErrorCode errorCode, Message message) { + switch (errorCode) { + case INTERNAL: + case QUOTA_EXCEEDED: + case INVALID_ARGUMENT: + case SENDER_ID_MISMATCH: + case THIRD_PARTY_AUTH_ERROR: + break; + case UNAVAILABLE: + // TODO: Could schedule for retry. + log.warn("The FCM service is unavailable."); + break; + case UNREGISTERED: + FcmUserDto userDto = new FcmUserDto(message.getUser()); + log.warn("The Device for user {} was unregistered.", userDto.getSubjectId()); + notificationService.removeNotificationsForUser( + userDto.getProjectId(), userDto.getSubjectId()); + dataMessageService.removeDataMessagesForUser( + userDto.getProjectId(), userDto.getSubjectId()); + userService.checkFcmTokenExistsAndReplace(userDto); + break; + } + } + +} diff --git a/src/main/java/org/radarbase/appserver/service/transmitter/NotificationTransmitter.java b/src/main/java/org/radarbase/appserver/service/transmitter/NotificationTransmitter.java new file mode 100644 index 00000000..6438344a --- /dev/null +++ b/src/main/java/org/radarbase/appserver/service/transmitter/NotificationTransmitter.java @@ -0,0 +1,8 @@ +package org.radarbase.appserver.service.transmitter; + +import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.exception.MessageTransmitException; + +public interface NotificationTransmitter { + void send(Notification notification) throws MessageTransmitException; +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index b5e7f81f..06cadc09 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -64,6 +64,14 @@ logging.pattern.console=%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr( # Firebase Cloud Messaging fcmserver.fcmsender=org.radarbase.fcm.downstream.AdminSdkFcmSender +# EMAIL SETTINGS +# needed when notifications via email are enabled +radar.notification.email.enabled=false +radar.notification.email.from=radar@radar.thehyve.nl +spring.mail.host=smtp.office365.com +spring.mail.port=587 +spring.mail.properties.mail.smtp.auth=false + # RADAR protocols radar.questionnaire.protocol.github.repo.path=RADAR-base/RADAR-aRMT-protocols radar.questionnaire.protocol.github.file.name=protocol.json diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 01d20eee..54ec9503 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -40,6 +40,18 @@ spring.quartz.properties.auto-startup=true #spring.quartz.properties.org.quartz.jobStore.useProperties = true spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate +# EMAIL SETTINGS +# needed when notifications via email are enabled +radar.notification.email.enabled=false +radar.notification.email.from=radar@radar.thehyve.nl +# For configuration of email use the Spring Boot mail properties (see https://docs.spring.io/spring-boot/reference/io/email.html) +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username= +spring.mail.password= +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + # LOGGING logging.level.root=INFO logging.level.org.springframework.web=INFO diff --git a/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240704090100_changelog.yml b/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240704090100_changelog.yml new file mode 100644 index 00000000..3b54f352 --- /dev/null +++ b/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240704090100_changelog.yml @@ -0,0 +1,11 @@ +databaseChangeLog: + - changeSet: + id: 12583923408-1 + author: pim + changes: + - addColumn: + columns: + - column: + name: email + type: VARCHAR(255) + tableName: users \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240705131000_changelog.yml b/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240705131000_changelog.yml new file mode 100644 index 00000000..b96d1e2a --- /dev/null +++ b/src/main/resources/db/changelog/changes/00000000000002_update_schema-20240705131000_changelog.yml @@ -0,0 +1,12 @@ +databaseChangeLog: + - changeSet: + id: 1720181460-1 + author: yatharth + changes: + - addColumn: + columns: + - column: + name: email_enabled + type: boolean + defaultValueBoolean: false + tableName: notifications \ No newline at end of file diff --git a/src/test/java/org/radarbase/appserver/service/FcmNotificationServiceTest.java b/src/test/java/org/radarbase/appserver/service/FcmNotificationServiceTest.java index ba9acfe1..2fcdc1e3 100644 --- a/src/test/java/org/radarbase/appserver/service/FcmNotificationServiceTest.java +++ b/src/test/java/org/radarbase/appserver/service/FcmNotificationServiceTest.java @@ -21,26 +21,6 @@ package org.radarbase.appserver.service; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.radarbase.appserver.controller.FcmNotificationControllerTest.FCM_MESSAGE_ID; -import static org.radarbase.appserver.controller.FcmNotificationControllerTest.PROJECT_ID; -import static org.radarbase.appserver.controller.FcmNotificationControllerTest.USER_ID; -import static org.radarbase.appserver.controller.RadarUserControllerTest.FCM_TOKEN_1; -import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_BODY; -import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_FCM_MESSAGE_ID; -import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_SOURCE_ID; -import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_TITLE; - -import java.time.Duration; -import java.time.Instant; -import java.util.Date; -import java.util.List; -import java.util.Optional; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -57,16 +37,35 @@ import org.radarbase.appserver.repository.NotificationRepository; import org.radarbase.appserver.repository.ProjectRepository; import org.radarbase.appserver.repository.UserRepository; -import org.radarbase.appserver.service.scheduler.NotificationSchedulerService; +import org.radarbase.appserver.service.scheduler.MessageSchedulerService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.radarbase.appserver.controller.FcmNotificationControllerTest.FCM_MESSAGE_ID; +import static org.radarbase.appserver.controller.FcmNotificationControllerTest.PROJECT_ID; +import static org.radarbase.appserver.controller.FcmNotificationControllerTest.USER_ID; +import static org.radarbase.appserver.controller.RadarUserControllerTest.FCM_TOKEN_1; +import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_BODY; +import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_FCM_MESSAGE_ID; +import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_SOURCE_ID; +import static org.radarbase.appserver.repository.NotificationRepositoryTest.NOTIFICATION_TITLE; + @SuppressWarnings("PMD.DataflowAnomalyAnalysis") @ExtendWith(SpringExtension.class) @DataJpaTest @@ -81,7 +80,7 @@ class FcmNotificationServiceTest { private static User user; private final transient Instant scheduledTime = Instant.now().plus(Duration.ofSeconds(100)); @MockBean - private transient NotificationSchedulerService schedulerService; + private transient MessageSchedulerService schedulerService; // TODO Make this generic when NotificationService interface is complete @Autowired private transient FcmNotificationService notificationService; @@ -538,7 +537,7 @@ static class FcmNotificationServiceTestContextConfiguration { @Autowired private transient ProjectRepository projectRepository; @Autowired - private transient NotificationSchedulerService schedulerService; + private transient MessageSchedulerService schedulerService; @Autowired private transient ApplicationEventPublisher eventPublisher; diff --git a/src/test/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerServiceTest.java b/src/test/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerServiceTest.java index 172d70b5..15c35fbf 100644 --- a/src/test/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerServiceTest.java +++ b/src/test/java/org/radarbase/appserver/service/scheduler/NotificationSchedulerServiceTest.java @@ -28,7 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.mock; -import static org.radarbase.appserver.controller.RadarUserControllerTest.TIMEZONE; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE; import java.time.Duration; @@ -61,6 +60,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.scheduling.quartz.JobDetailFactoryBean; import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -80,7 +80,7 @@ class NotificationSchedulerServiceTest { private static final String JOB_DETAIL_ID = "message-jobdetail-test-subject-1"; private static Notification notification; @Autowired - private transient NotificationSchedulerService notificationSchedulerService; + private transient MessageSchedulerService schedulerService; @Autowired private transient Scheduler scheduler; @@ -113,15 +113,16 @@ void setUp() throws SchedulerException { .build(); } - @Test - void sendNotification() { - assertDoesNotThrow(() -> notificationSchedulerService.send(notification)); - } + // TODO move this test for message transmitters + // @Test +// void sendNotification() { +// assertDoesNotThrow(() -> schedulerService.send(notification)); +// } @Test void scheduleNotification() throws InterruptedException, SchedulerException { scheduler.getListenerManager().addJobListener(new TestJobListener()); - notificationSchedulerService.schedule(notification); + schedulerService.schedule(notification); // sleep for 5 seconds for the job to be executed. // Assert statements are in the listener. @@ -131,7 +132,7 @@ void scheduleNotification() throws InterruptedException, SchedulerException { @Test void scheduleNotifications() throws InterruptedException, SchedulerException { scheduler.getListenerManager().addJobListener(new TestJobListener()); - notificationSchedulerService.scheduleMultiple(List.of(notification)); + schedulerService.scheduleMultiple(List.of(notification)); Thread.sleep(5000); } @@ -140,9 +141,9 @@ void scheduleNotifications() throws InterruptedException, SchedulerException { void updateScheduledNotification() throws SchedulerException { // given JobDetailFactoryBean jobDetail = - NotificationSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); + MessageSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); SimpleTriggerFactoryBean triggerFactoryBean = - NotificationSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); + MessageSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); scheduler.scheduleJob(jobDetail.getObject(), triggerFactoryBean.getObject()); Notification notification2 = @@ -154,7 +155,7 @@ void updateScheduledNotification() throws SchedulerException { .build(); // when - notificationSchedulerService.updateScheduled(notification2); + schedulerService.updateScheduled(notification2); assertTrue(scheduler.checkExists(new JobKey(JOB_DETAIL_ID))); @@ -178,15 +179,15 @@ void updateScheduledNotification() throws SchedulerException { void deleteScheduledNotifications() throws SchedulerException { // given JobDetailFactoryBean jobDetail = - NotificationSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); + MessageSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); SimpleTriggerFactoryBean triggerFactoryBean = - NotificationSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); + MessageSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); scheduler.scheduleJob(jobDetail.getObject(), triggerFactoryBean.getObject()); assertTrue(scheduler.checkExists(new JobKey(JOB_DETAIL_ID))); // when - notificationSchedulerService.deleteScheduledMultiple(List.of(notification)); + schedulerService.deleteScheduledMultiple(List.of(notification)); assertFalse(scheduler.checkExists(new JobKey(JOB_DETAIL_ID))); } @@ -196,15 +197,15 @@ void deleteScheduledNotification() throws SchedulerException { // given JobDetailFactoryBean jobDetail = - NotificationSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); + MessageSchedulerService.getJobDetailForMessage(notification, MessageType.NOTIFICATION); SimpleTriggerFactoryBean triggerFactoryBean = - NotificationSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); + MessageSchedulerService.getTriggerForMessage(notification, jobDetail.getObject()); scheduler.scheduleJob(jobDetail.getObject(), triggerFactoryBean.getObject()); assertTrue(scheduler.checkExists(new JobKey(JOB_DETAIL_ID))); // when - notificationSchedulerService.deleteScheduled(notification); + schedulerService.deleteScheduled(notification); assertFalse(scheduler.checkExists(new JobKey(JOB_DETAIL_ID))); } @@ -215,10 +216,11 @@ static class SchedulerServiceTestConfig { private transient Scheduler scheduler; @Bean - public NotificationSchedulerService schedulerServiceBeanConfig() { + @Primary + public MessageSchedulerService schedulerServiceBeanConfig() { // mock FCM as we do not want to connect to the server - return new NotificationSchedulerService( + return new MessageSchedulerService( mock(FcmSender.class), new SchedulerServiceImpl(scheduler)); } } diff --git a/src/test/java/org/radarbase/appserver/service/scheduler/quartz/MessageJobTest.java b/src/test/java/org/radarbase/appserver/service/scheduler/quartz/MessageJobTest.java new file mode 100644 index 00000000..3db86dee --- /dev/null +++ b/src/test/java/org/radarbase/appserver/service/scheduler/quartz/MessageJobTest.java @@ -0,0 +1,130 @@ +package org.radarbase.appserver.service.scheduler.quartz; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionException; +import org.quartz.SchedulerException; +import org.quartz.impl.JobDetailImpl; +import org.quartz.impl.JobExecutionContextImpl; +import org.radarbase.appserver.entity.DataMessage; +import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.exception.EmailMessageTransmitException; +import org.radarbase.appserver.exception.FcmMessageTransmitException; +import org.radarbase.appserver.exception.MessageTransmitException; +import org.radarbase.appserver.service.FcmDataMessageService; +import org.radarbase.appserver.service.FcmNotificationService; +import org.radarbase.appserver.service.MessageType; +import org.radarbase.appserver.service.transmitter.DataMessageTransmitter; +import org.radarbase.appserver.service.transmitter.NotificationTransmitter; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = {MessageJob.class}) +@ExtendWith(SpringExtension.class) +class MessageJobTest { + + private MessageJob messageJob; + + @MockBean + private FcmDataMessageService fcmDataMessageService; + + @MockBean + private FcmNotificationService fcmNotificationService; + + @MockBean + private NotificationTransmitter notificationTransmitter; + + @MockBean + private DataMessageTransmitter dataMessageTransmitter; + + private JobDataMap jobDataMap; + private JobExecutionContextImpl jobExecutionContext; + private Notification notification; + private DataMessage dataMessage; + + @BeforeEach + void setUp() { + List notificationTransmitters = new ArrayList<>(); + notificationTransmitters.add(notificationTransmitter); + notificationTransmitters.add(notificationTransmitter); + + List dataMessageTransmitters = new ArrayList<>(); + dataMessageTransmitters.add(dataMessageTransmitter); + dataMessageTransmitters.add(dataMessageTransmitter); + + messageJob = new MessageJob(notificationTransmitters, dataMessageTransmitters, fcmNotificationService, fcmDataMessageService); + + jobExecutionContext = mock(JobExecutionContextImpl.class); + JobDetailImpl job = mock(JobDetailImpl.class); + jobDataMap = mock(JobDataMap.class); + when(job.getJobDataMap()).thenReturn(jobDataMap); + when(jobDataMap.getString("projectId")).thenReturn("projectId"); + when(jobDataMap.getString("subjectId")).thenReturn("subjectId"); + when(jobDataMap.getLong("messageId")).thenReturn(1L); + setNotificationType(MessageType.NOTIFICATION); + + when(jobExecutionContext.getJobDetail()).thenReturn(job); + + notification = mock(Notification.class); + dataMessage = mock(DataMessage.class); + when(fcmNotificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId("projectId", "subjectId", 1L)).thenReturn(notification); + when(fcmDataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId("projectId", "subjectId", 1L)).thenReturn(dataMessage); + } + + @Test + void testExecuteNotification() throws SchedulerException, MessageTransmitException { + setNotificationType(MessageType.NOTIFICATION); + messageJob.execute(jobExecutionContext); + verify(notificationTransmitter, times(2)).send(notification); + verify(dataMessageTransmitter, never()).send(any()); + } + + @Test + void testExecuteDataMessage() throws SchedulerException, MessageTransmitException { + setNotificationType(MessageType.DATA); + messageJob.execute(jobExecutionContext); + verify(notificationTransmitter, never()).send(any()); + verify(dataMessageTransmitter, times(2)).send(dataMessage); + } + + @Test + void testIsSilentOnEmailTransmissionException() throws MessageTransmitException { + setNotificationType(MessageType.NOTIFICATION); + doThrow( + new EmailMessageTransmitException("Email exception"), + new EmailMessageTransmitException("Email exception") + ).when(notificationTransmitter).send(notification); + assertDoesNotThrow(() -> messageJob.execute(jobExecutionContext)); + } + + @Test + void testExplodesOnFcmTransmissionException() throws MessageTransmitException { + setNotificationType(MessageType.NOTIFICATION); + doThrow( + new EmailMessageTransmitException("Email exception"), + new FcmMessageTransmitException("Fcm exception") + ).when(notificationTransmitter).send(notification); + assertThrows(JobExecutionException.class, () -> messageJob.execute(jobExecutionContext)); + } + + private void setNotificationType(MessageType messageType) { + when(jobDataMap.getString("messageType")).thenReturn(messageType.name()); + } + +} diff --git a/src/test/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitterTest.java b/src/test/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitterTest.java new file mode 100644 index 00000000..ff268a08 --- /dev/null +++ b/src/test/java/org/radarbase/appserver/service/transmitter/EmailNotificationTransmitterTest.java @@ -0,0 +1,73 @@ +package org.radarbase.appserver.service.transmitter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.radarbase.appserver.entity.Notification; +import org.radarbase.appserver.entity.User; +import org.radarbase.appserver.exception.EmailMessageTransmitException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(SpringExtension.class) +@SpringBootTest( + classes = {EmailNotificationTransmitter.class}, + properties = { + "radar.notification.email.enabled=true" + } +) +class EmailNotificationTransmitterTest { + @Autowired + private EmailNotificationTransmitter emailNotificationTransmitter; + + @MockBean + private JavaMailSender javaMailSender; + + @Test + void testSend() { + Notification validNotification = buildNotification(); + assertDoesNotThrow(() -> emailNotificationTransmitter.send(validNotification), "Valid Notification should not throw an exception"); + verify(javaMailSender, times(1)).send(isA(SimpleMailMessage.class)); + } + + @Test + void testExceptionNotThrownForMissingEmail() { + Notification invalidNotification = buildNotification(); + when(invalidNotification.getUser().getEmailAddress()).thenReturn(null); + assertDoesNotThrow(() -> emailNotificationTransmitter.send(invalidNotification), "Notification with User w/o an email address should not throw an exception"); + when(invalidNotification.getUser().getEmailAddress()).thenReturn(""); + assertDoesNotThrow(() -> emailNotificationTransmitter.send(invalidNotification), "Notification with User w/o an email address should not throw an exception"); + } + + @Test + void testExceptionThrownWithEmailFailure() { + doThrow(mock(MailException.class)).when(javaMailSender).send(any(SimpleMailMessage.class)); + Notification validNotification = buildNotification(); + assertThrows(EmailMessageTransmitException.class, () -> emailNotificationTransmitter.send(validNotification), "Problems during sending of email notifications should throw an exception"); + } + + private Notification buildNotification() { + User user = mock(User.class); + when(user.getEmailAddress()).thenReturn("hello@here.com"); + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(user); + when(notification.getTitle()).thenReturn("Title"); + when(notification.getBody()).thenReturn("Body"); + when(notification.isEmailEnabled()).thenReturn(true); + return notification; + } +}