From e616970e57c1d790ee4e41106f364b126c71b70a Mon Sep 17 00:00:00 2001 From: Mohsen Karimi Date: Tue, 4 Mar 2025 21:58:51 +0100 Subject: [PATCH 1/3] feat: add notification system with event handling and email support Signed-off-by: Mohsen Karimi --- build.gradle | 2 + .../event/OrganizationCreatedEvent.java | 2 + .../auth/domain/event/UserEventPayload.java | 11 +- .../teamwize/api/base/error/ApiErrorItem.java | 14 -- .../api/event/entity/EventEntity.java | 2 +- .../api/event/model/EventExitCode.java | 2 +- .../teamwize/api/event/model/EventType.java | 1 + .../api/event/service/EventService.java | 12 +- .../handler/NotificationEventHandler.java | 37 ----- .../leave/model/event/LeaveCreatedEvent.java | 2 + .../leave/model/event/LeaveEventPayload.java | 5 +- .../LeavePolicyActivatedTypePayload.java | 23 +++ .../model/event/LeaveStatusUpdatedEvent.java | 3 + .../api/leave/service/LeaveService.java | 7 +- .../api/notification/config/MailConfig.java | 31 ++++ .../config/TemplateEngineConfig.java | 15 ++ .../NotificationNotFoundException.java | 11 ++ .../NotificationSendFailureException.java | 9 ++ .../NotificationTemplateCompileException.java | 9 ++ .../NotificationTriggerNotFoundException.java | 11 ++ .../mapper/NotificationMapper.java | 20 +++ .../mapper/NotificationTriggerMapper.java | 20 +++ .../api/notification/model/Notification.java | 25 +++ .../model/NotificationChannel.java | 7 + .../model/NotificationStatus.java | 8 + .../model/NotificationTrigger.java | 17 ++ .../model/NotificationTriggerReceptors.java | 9 ++ .../model/NotificationTriggerStatus.java | 7 + .../command/NotificationCreateCommand.java | 18 +++ .../command/NotificationFilterCommand.java | 7 + .../NotificationTriggerCreateCommand.java | 16 ++ .../NotificationTriggerUpdateCommand.java | 17 ++ .../model/entity/NotificationEntity.java | 64 ++++++++ .../entity/NotificationTriggerEntity.java | 47 ++++++ .../model/event/NotificationCreatedEvent.java | 25 +++ .../model/event/NotificationEventPayload.java | 42 +++++ .../NotificationTriggerEventPayload.java | 27 ++++ .../repository/NotificationRepository.java | 20 +++ .../NotificationSpecifications.java | 37 +++++ .../NotificationTriggerRepository.java | 21 +++ .../controller/NotificationController.java | 97 ++++++++++++ .../rest/mapper/NotificationRestMapper.java | 30 ++++ .../request/NotificationFilterRequest.java | 7 + .../NotificationTriggerCreateRequest.java | 16 ++ .../NotificationTriggerUpdateRequest.java | 15 ++ .../model/response/NotificationResponse.java | 23 +++ .../NotificationTriggerCompactResponse.java | 12 ++ .../response/NotificationTriggerResponse.java | 20 +++ .../NotificationCreatedEventHandler.java | 58 +++++++ .../service/NotificationEventHandler.java | 149 ++++++++++++++++++ .../service/NotificationService.java | 123 +++++++++++++++ .../service/NotificationTriggerService.java | 83 ++++++++++ .../service/notifier/EmailNotifier.java | 62 ++++++++ .../service/notifier/Notifier.java | 12 ++ .../user/domain/event/UserInvitedEvent.java | 3 +- .../api/user/listener/UserEventListener.java | 31 ---- .../user/listener/UserEventSQSListener.java | 39 ----- .../api/user/repository/UserRepository.java | 10 ++ .../api/user/service/UserService.java | 15 ++ src/main/resources/application-local.yml | 18 ++- src/main/resources/application.yml | 13 +- src/main/resources/templates/email/layout.hbs | 51 ++++++ 62 files changed, 1411 insertions(+), 139 deletions(-) delete mode 100644 src/main/java/app/teamwize/api/base/error/ApiErrorItem.java delete mode 100644 src/main/java/app/teamwize/api/event/service/handler/NotificationEventHandler.java create mode 100644 src/main/java/app/teamwize/api/leave/model/event/LeavePolicyActivatedTypePayload.java create mode 100644 src/main/java/app/teamwize/api/notification/config/MailConfig.java create mode 100644 src/main/java/app/teamwize/api/notification/config/TemplateEngineConfig.java create mode 100644 src/main/java/app/teamwize/api/notification/exception/NotificationNotFoundException.java create mode 100644 src/main/java/app/teamwize/api/notification/exception/NotificationSendFailureException.java create mode 100644 src/main/java/app/teamwize/api/notification/exception/NotificationTemplateCompileException.java create mode 100644 src/main/java/app/teamwize/api/notification/exception/NotificationTriggerNotFoundException.java create mode 100644 src/main/java/app/teamwize/api/notification/mapper/NotificationMapper.java create mode 100644 src/main/java/app/teamwize/api/notification/mapper/NotificationTriggerMapper.java create mode 100644 src/main/java/app/teamwize/api/notification/model/Notification.java create mode 100644 src/main/java/app/teamwize/api/notification/model/NotificationChannel.java create mode 100644 src/main/java/app/teamwize/api/notification/model/NotificationStatus.java create mode 100644 src/main/java/app/teamwize/api/notification/model/NotificationTrigger.java create mode 100644 src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java create mode 100644 src/main/java/app/teamwize/api/notification/model/NotificationTriggerStatus.java create mode 100644 src/main/java/app/teamwize/api/notification/model/command/NotificationCreateCommand.java create mode 100644 src/main/java/app/teamwize/api/notification/model/command/NotificationFilterCommand.java create mode 100644 src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java create mode 100644 src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerUpdateCommand.java create mode 100644 src/main/java/app/teamwize/api/notification/model/entity/NotificationEntity.java create mode 100644 src/main/java/app/teamwize/api/notification/model/entity/NotificationTriggerEntity.java create mode 100644 src/main/java/app/teamwize/api/notification/model/event/NotificationCreatedEvent.java create mode 100644 src/main/java/app/teamwize/api/notification/model/event/NotificationEventPayload.java create mode 100644 src/main/java/app/teamwize/api/notification/model/event/NotificationTriggerEventPayload.java create mode 100644 src/main/java/app/teamwize/api/notification/repository/NotificationRepository.java create mode 100644 src/main/java/app/teamwize/api/notification/repository/NotificationSpecifications.java create mode 100644 src/main/java/app/teamwize/api/notification/repository/NotificationTriggerRepository.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/controller/NotificationController.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/mapper/NotificationRestMapper.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/request/NotificationFilterRequest.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerCreateRequest.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerUpdateRequest.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/response/NotificationResponse.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerCompactResponse.java create mode 100644 src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerResponse.java create mode 100644 src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java create mode 100644 src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java create mode 100644 src/main/java/app/teamwize/api/notification/service/NotificationService.java create mode 100644 src/main/java/app/teamwize/api/notification/service/NotificationTriggerService.java create mode 100644 src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java create mode 100644 src/main/java/app/teamwize/api/notification/service/notifier/Notifier.java delete mode 100644 src/main/java/app/teamwize/api/user/listener/UserEventListener.java delete mode 100644 src/main/java/app/teamwize/api/user/listener/UserEventSQSListener.java create mode 100644 src/main/resources/templates/email/layout.hbs diff --git a/build.gradle b/build.gradle index 29d79fa..ee45a64 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation("software.amazon.awssdk:s3") implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -46,6 +47,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' implementation 'com.auth0:java-jwt:4.3.0' + implementation 'com.github.jknack:handlebars:4.4.0' compileOnly 'com.google.code.findbugs:jsr305:3.0.1' diff --git a/src/main/java/app/teamwize/api/auth/domain/event/OrganizationCreatedEvent.java b/src/main/java/app/teamwize/api/auth/domain/event/OrganizationCreatedEvent.java index d492532..71b4346 100644 --- a/src/main/java/app/teamwize/api/auth/domain/event/OrganizationCreatedEvent.java +++ b/src/main/java/app/teamwize/api/auth/domain/event/OrganizationCreatedEvent.java @@ -2,9 +2,11 @@ import app.teamwize.api.event.model.EventPayload; import app.teamwize.api.event.model.EventType; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; +@Schema(name = "ORGANIZATION_CREATED", description = "Organization created event") public record OrganizationCreatedEvent(UserEventPayload user, OrganizationEventPayload organization) implements EventPayload { @Override diff --git a/src/main/java/app/teamwize/api/auth/domain/event/UserEventPayload.java b/src/main/java/app/teamwize/api/auth/domain/event/UserEventPayload.java index c35ef8e..119b83c 100644 --- a/src/main/java/app/teamwize/api/auth/domain/event/UserEventPayload.java +++ b/src/main/java/app/teamwize/api/auth/domain/event/UserEventPayload.java @@ -7,23 +7,26 @@ public record UserEventPayload( Long id, UserRole role, String email, - String password, String phone, String firstName, String lastName, String country, - String timezone) { + String timezone, + Long teamId, + Long organizationId +) { public UserEventPayload(User user) { this(user.getId(), user.getRole(), user.getEmail(), - user.getPassword(), user.getPhone(), user.getFirstName(), user.getLastName(), user.getCountry(), - user.getTimezone() + user.getTimezone(), + user.getTeam().getId(), + user.getOrganization().getId() ); } } \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/base/error/ApiErrorItem.java b/src/main/java/app/teamwize/api/base/error/ApiErrorItem.java deleted file mode 100644 index f86b334..0000000 --- a/src/main/java/app/teamwize/api/base/error/ApiErrorItem.java +++ /dev/null @@ -1,14 +0,0 @@ -package app.teamwize.api.base.error; - -import lombok.Builder; -import lombok.Data; - -import java.util.Map; - -@Data -@Builder -public class ApiErrorItem { - String code; - String message; - Map arguments; -} diff --git a/src/main/java/app/teamwize/api/event/entity/EventEntity.java b/src/main/java/app/teamwize/api/event/entity/EventEntity.java index a315195..e1878dd 100644 --- a/src/main/java/app/teamwize/api/event/entity/EventEntity.java +++ b/src/main/java/app/teamwize/api/event/entity/EventEntity.java @@ -42,6 +42,6 @@ public class EventEntity extends BaseAuditEntity { @JoinColumn(name = "organization_id") private Organization organization; - @OneToMany(mappedBy = "event") + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List executions; } diff --git a/src/main/java/app/teamwize/api/event/model/EventExitCode.java b/src/main/java/app/teamwize/api/event/model/EventExitCode.java index 9001bb5..64d3b5a 100644 --- a/src/main/java/app/teamwize/api/event/model/EventExitCode.java +++ b/src/main/java/app/teamwize/api/event/model/EventExitCode.java @@ -7,7 +7,7 @@ public enum EventExitCode { HANDLER_NOT_FOUND,// No appropriate handler was found for the event type; event could not be processed due to lack of support - PROCESSING_ERROR, // An error occurred during event processing that prevented successful completion + ERROR, // An error occurred during event processing that prevented successful completion CONNECTION_ERROR, // Failed to connect to an external service required for event processing (e.g., email/SMS server) diff --git a/src/main/java/app/teamwize/api/event/model/EventType.java b/src/main/java/app/teamwize/api/event/model/EventType.java index f2bb61f..8dbb76b 100644 --- a/src/main/java/app/teamwize/api/event/model/EventType.java +++ b/src/main/java/app/teamwize/api/event/model/EventType.java @@ -6,4 +6,5 @@ public enum EventType { LEAVE_CREATED, LEAVE_STATUS_UPDATED, TEAM_CREATED, + NOTIFICATION_CREATED, } diff --git a/src/main/java/app/teamwize/api/event/service/EventService.java b/src/main/java/app/teamwize/api/event/service/EventService.java index 0b67a3f..1aed1e9 100644 --- a/src/main/java/app/teamwize/api/event/service/EventService.java +++ b/src/main/java/app/teamwize/api/event/service/EventService.java @@ -12,6 +12,7 @@ import app.teamwize.api.event.service.handler.EventHandler; import app.teamwize.api.organization.domain.entity.Organization; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Scheduled; @@ -28,6 +29,7 @@ public class EventService { private final EventRepository eventRepository; private final EventExecutionRepository executionRepository; + @Lazy private final List eventHandlers; private final EventMapper eventMapper; @@ -36,15 +38,19 @@ public class EventService { public Event emmit(Long organizationId, EventType eventType, Map params, byte maxAttempts, Instant scheduledAt) { var executions = eventHandlers.stream().filter(eventHandler -> eventHandler.accepts(eventType)).map(eventHandler -> new EventExecutionEntity() .setStatus(EventExecutionStatus.PENDING) + .setAttempts(0) .setHandler(eventHandler.name())).toList(); var event = new EventEntity() .setOrganization(new Organization(organizationId)) .setType(eventType) - .setParams(Map.of()) + .setParams(params) .setStatus(EventStatus.PENDING) .setMaxAttempts(maxAttempts) .setScheduledAt(scheduledAt) .setExecutions(executions); + + executions.forEach(eventExecutionEntity -> eventExecutionEntity.setEvent(event)); + return eventMapper.toEvent(eventRepository.persist(event)); } @@ -58,7 +64,7 @@ public Event emmit(Long organizationId, EventPayload eventPayload) { // There is no need to have exitCode for events they are not jobs @Transactional - @Scheduled(fixedDelay = 60_000) + @Scheduled(fixedDelay = 10_000) public void processEvents() { var pendingEvents = eventRepository.findByStatus(EventStatus.PENDING); for (var pendingEvent : pendingEvents) { @@ -82,7 +88,9 @@ public void processEvents() { } executionRepository.update(execution); } + pendingEvent.setStatus(EventStatus.FINISHED); } + eventRepository.updateAll(pendingEvents); } public Paged getEvents(Pagination pagination) { diff --git a/src/main/java/app/teamwize/api/event/service/handler/NotificationEventHandler.java b/src/main/java/app/teamwize/api/event/service/handler/NotificationEventHandler.java deleted file mode 100644 index aa36910..0000000 --- a/src/main/java/app/teamwize/api/event/service/handler/NotificationEventHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package app.teamwize.api.event.service.handler; - -import app.teamwize.api.event.entity.EventEntity; -import app.teamwize.api.event.model.EventExitCode; -import app.teamwize.api.event.model.EventType; -import org.springframework.stereotype.Component; - -import java.util.Map; - -@Component -public class NotificationEventHandler implements EventHandler { - @Override - public String name() { - return this.getClass().getSimpleName(); - } - - @Override - public boolean accepts(EventType type) { - return true; - } - - /* - Steps : - - Try to get trigger and get list of channels - - Try to call notification service and make notifications - - Try to persist the result of notification - - Decide about the cases that coudnl't deliver one of them - - WebHooks should be repeated multiple times - - We should retry to broken ones - - We can add metadata to the event to skip some of processing that already done like MetaData - */ - - @Override - public EventExecutionResult process(EventEntity eventEntity) { - return new EventExecutionResult(EventExitCode.SUCCESS, Map.of("notificationId", 1000L)); - } -} diff --git a/src/main/java/app/teamwize/api/leave/model/event/LeaveCreatedEvent.java b/src/main/java/app/teamwize/api/leave/model/event/LeaveCreatedEvent.java index 2c16fe8..c3b58ff 100644 --- a/src/main/java/app/teamwize/api/leave/model/event/LeaveCreatedEvent.java +++ b/src/main/java/app/teamwize/api/leave/model/event/LeaveCreatedEvent.java @@ -3,9 +3,11 @@ import app.teamwize.api.auth.domain.event.UserEventPayload; import app.teamwize.api.event.model.EventPayload; import app.teamwize.api.event.model.EventType; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; +@Schema(name = "LEAVE_CREATED", description = "Leave created event") public record LeaveCreatedEvent(LeaveEventPayload leave, UserEventPayload user) implements EventPayload { @Override diff --git a/src/main/java/app/teamwize/api/leave/model/event/LeaveEventPayload.java b/src/main/java/app/teamwize/api/leave/model/event/LeaveEventPayload.java index d258c1d..04240dc 100644 --- a/src/main/java/app/teamwize/api/leave/model/event/LeaveEventPayload.java +++ b/src/main/java/app/teamwize/api/leave/model/event/LeaveEventPayload.java @@ -2,7 +2,6 @@ import app.teamwize.api.leave.model.LeaveStatus; import app.teamwize.api.leave.model.entity.Leave; -import app.teamwize.api.leave.model.entity.LeavePolicyActivatedType; import java.time.Instant; @@ -14,7 +13,7 @@ public record LeaveEventPayload( LeaveStatus status, - LeavePolicyActivatedType type, + LeavePolicyActivatedTypePayload type, String reason, @@ -26,7 +25,7 @@ public LeaveEventPayload(Leave leave) { leave.getStartAt(), leave.getEndAt(), leave.getStatus(), - leave.getActivatedType(), + new LeavePolicyActivatedTypePayload(leave.getActivatedType()), leave.getReason(), leave.getDuration() ); diff --git a/src/main/java/app/teamwize/api/leave/model/event/LeavePolicyActivatedTypePayload.java b/src/main/java/app/teamwize/api/leave/model/event/LeavePolicyActivatedTypePayload.java new file mode 100644 index 0000000..0843a07 --- /dev/null +++ b/src/main/java/app/teamwize/api/leave/model/event/LeavePolicyActivatedTypePayload.java @@ -0,0 +1,23 @@ +package app.teamwize.api.leave.model.event; + +import app.teamwize.api.leave.model.entity.LeavePolicyActivatedType; + +public record LeavePolicyActivatedTypePayload( + String name, + Boolean requiresApproval, + Integer amount, + Long typeId, + Long policyId, + String policyName) { + + public LeavePolicyActivatedTypePayload(LeavePolicyActivatedType leavePolicyActivatedType) { + this(leavePolicyActivatedType.getType().getName(), + leavePolicyActivatedType.getRequiresApproval(), + leavePolicyActivatedType.getAmount(), + leavePolicyActivatedType.getId().getTypeId(), + leavePolicyActivatedType.getId().getPolicyId(), + leavePolicyActivatedType.getPolicy().getName() + ); + } + +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/leave/model/event/LeaveStatusUpdatedEvent.java b/src/main/java/app/teamwize/api/leave/model/event/LeaveStatusUpdatedEvent.java index a0e65a8..9a7601b 100644 --- a/src/main/java/app/teamwize/api/leave/model/event/LeaveStatusUpdatedEvent.java +++ b/src/main/java/app/teamwize/api/leave/model/event/LeaveStatusUpdatedEvent.java @@ -3,8 +3,11 @@ import app.teamwize.api.auth.domain.event.UserEventPayload; import app.teamwize.api.event.model.EventPayload; import app.teamwize.api.event.model.EventType; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.Map; +@Schema(name = "LEAVE_STATUS_UPDATED", description = "Leave status updated event") public record LeaveStatusUpdatedEvent(LeaveEventPayload leave, UserEventPayload user) implements EventPayload { @Override diff --git a/src/main/java/app/teamwize/api/leave/service/LeaveService.java b/src/main/java/app/teamwize/api/leave/service/LeaveService.java index 771df7d..513b602 100644 --- a/src/main/java/app/teamwize/api/leave/service/LeaveService.java +++ b/src/main/java/app/teamwize/api/leave/service/LeaveService.java @@ -17,6 +17,7 @@ import app.teamwize.api.leave.model.command.LeaveUpdateCommand; import app.teamwize.api.leave.model.entity.Leave; import app.teamwize.api.leave.model.entity.LeavePolicyActivatedTypeId; +import app.teamwize.api.leave.model.event.LeaveCreatedEvent; import app.teamwize.api.leave.model.event.LeaveEventPayload; import app.teamwize.api.leave.model.event.LeaveStatusUpdatedEvent; import app.teamwize.api.leave.repository.LeaveRepository; @@ -82,7 +83,7 @@ public Leave createLeave(Long organizationId, Long userId, LeaveCreateCommand co dayOff = leaveRepository.persist(dayOff); - // eventService.emmit(organizationId, new LeaveCreatedEvent(new LeaveEventPayload(dayOff), new UserEventPayload(user))); + eventService.emmit(organizationId, new LeaveCreatedEvent(new LeaveEventPayload(dayOff), new UserEventPayload(user))); return dayOff; } @@ -206,6 +207,4 @@ public LeaveCheckResult checkRequestedLeave(Long organizationId, Long userId, Le ); } -} -// Range 17 April to 25 April -// we want to know the leave request that started between 17 to 25 April or ended between 17 to 25 April \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/config/MailConfig.java b/src/main/java/app/teamwize/api/notification/config/MailConfig.java new file mode 100644 index 0000000..1a6ca95 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/config/MailConfig.java @@ -0,0 +1,31 @@ +package app.teamwize.api.notification.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + + +@Configuration +public class MailConfig { + + @Bean + public JavaMailSender getJavaMailSender(org.springframework.core.env.Environment env) { + var mailSender = new JavaMailSenderImpl(); + mailSender.setHost(env.getProperty("spring.mail.host")); + mailSender.setPort(Integer.parseInt(env.getProperty("spring.mail.port", "587"))); + mailSender.setUsername(env.getProperty("spring.mail.username")); + mailSender.setPassword(env.getProperty("spring.mail.password")); + + var props = mailSender.getJavaMailProperties(); + props.put("mail.smtp.auth", env.getProperty("spring.mail.properties.mail.smtp.auth")); + props.put("mail.smtp.starttls.enable", env.getProperty("spring.mail.properties.mail.smtp.starttls.enable")); + props.put("mail.debug", "false"); // Set to true for debugging + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.from", env.getProperty("spring.mail.username")); + props.put("mail.smtp.connectiontimeout", "5000"); + props.put("mail.smtp.timeout", "5000"); + props.put("mail.smtp.writetimeout", "5000"); + return mailSender; + } +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/config/TemplateEngineConfig.java b/src/main/java/app/teamwize/api/notification/config/TemplateEngineConfig.java new file mode 100644 index 0000000..41fc016 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/config/TemplateEngineConfig.java @@ -0,0 +1,15 @@ +package app.teamwize.api.notification.config; + +import com.github.jknack.handlebars.Handlebars; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TemplateEngineConfig { + + @Bean + public Handlebars templateEngine() { + return new Handlebars(); + } + +} diff --git a/src/main/java/app/teamwize/api/notification/exception/NotificationNotFoundException.java b/src/main/java/app/teamwize/api/notification/exception/NotificationNotFoundException.java new file mode 100644 index 0000000..c6562b0 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/exception/NotificationNotFoundException.java @@ -0,0 +1,11 @@ +package app.teamwize.api.notification.exception; + +import app.teamwize.api.base.exception.NotFoundException; + +public class NotificationNotFoundException extends NotFoundException { + + + public NotificationNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/app/teamwize/api/notification/exception/NotificationSendFailureException.java b/src/main/java/app/teamwize/api/notification/exception/NotificationSendFailureException.java new file mode 100644 index 0000000..31a3a9a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/exception/NotificationSendFailureException.java @@ -0,0 +1,9 @@ +package app.teamwize.api.notification.exception; + +import app.teamwize.api.base.exception.ServerException; + +public class NotificationSendFailureException extends ServerException { + public NotificationSendFailureException(String message) { + super(message); + } +} diff --git a/src/main/java/app/teamwize/api/notification/exception/NotificationTemplateCompileException.java b/src/main/java/app/teamwize/api/notification/exception/NotificationTemplateCompileException.java new file mode 100644 index 0000000..c206499 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/exception/NotificationTemplateCompileException.java @@ -0,0 +1,9 @@ +package app.teamwize.api.notification.exception; + +import app.teamwize.api.base.exception.ServerException; + +public class NotificationTemplateCompileException extends ServerException { + public NotificationTemplateCompileException(String message) { + super(message); + } +} diff --git a/src/main/java/app/teamwize/api/notification/exception/NotificationTriggerNotFoundException.java b/src/main/java/app/teamwize/api/notification/exception/NotificationTriggerNotFoundException.java new file mode 100644 index 0000000..572752c --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/exception/NotificationTriggerNotFoundException.java @@ -0,0 +1,11 @@ +package app.teamwize.api.notification.exception; + +import app.teamwize.api.base.exception.NotFoundException; + +public class NotificationTriggerNotFoundException extends NotFoundException { + + + public NotificationTriggerNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/app/teamwize/api/notification/mapper/NotificationMapper.java b/src/main/java/app/teamwize/api/notification/mapper/NotificationMapper.java new file mode 100644 index 0000000..a1b40c6 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/mapper/NotificationMapper.java @@ -0,0 +1,20 @@ +package app.teamwize.api.notification.mapper; + +import app.teamwize.api.base.config.DefaultMapperConfig; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.command.NotificationCreateCommand; +import app.teamwize.api.notification.model.entity.NotificationEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(config = DefaultMapperConfig.class) +public interface NotificationMapper { + @Mapping(target = "trigger", ignore = true) + NotificationEntity toEntity(NotificationCreateCommand command); + + Notification toModel(NotificationEntity entity); + + List toModels(List entities); +} diff --git a/src/main/java/app/teamwize/api/notification/mapper/NotificationTriggerMapper.java b/src/main/java/app/teamwize/api/notification/mapper/NotificationTriggerMapper.java new file mode 100644 index 0000000..7b45058 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/mapper/NotificationTriggerMapper.java @@ -0,0 +1,20 @@ +package app.teamwize.api.notification.mapper; + +import app.teamwize.api.base.config.DefaultMapperConfig; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.command.NotificationTriggerCreateCommand; +import app.teamwize.api.notification.model.entity.NotificationTriggerEntity; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(config = DefaultMapperConfig.class) +public interface NotificationTriggerMapper { + NotificationTrigger toModel(NotificationTriggerEntity entity); + + NotificationTriggerEntity toEntity(NotificationTriggerCreateCommand notificationTrigger); + + NotificationTriggerEntity toEntity(NotificationTrigger notificationTrigger); + + List toModels(List triggers); +} diff --git a/src/main/java/app/teamwize/api/notification/model/Notification.java b/src/main/java/app/teamwize/api/notification/model/Notification.java new file mode 100644 index 0000000..3a633c7 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/Notification.java @@ -0,0 +1,25 @@ +package app.teamwize.api.notification.model; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.organization.domain.entity.Organization; +import app.teamwize.api.user.domain.entity.User; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record Notification( + Long id, + String title, + User user, + NotificationTrigger trigger, + String textContent, + String htmlContent, + EventType eventType, + Organization organization, + Map params, + List channels, + Instant sentAt, + NotificationStatus status +) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationChannel.java b/src/main/java/app/teamwize/api/notification/model/NotificationChannel.java new file mode 100644 index 0000000..0059003 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/NotificationChannel.java @@ -0,0 +1,7 @@ +package app.teamwize.api.notification.model; + +public enum NotificationChannel { + EMAIL, + SLACK +} + diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationStatus.java b/src/main/java/app/teamwize/api/notification/model/NotificationStatus.java new file mode 100644 index 0000000..268d60b --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/NotificationStatus.java @@ -0,0 +1,8 @@ +package app.teamwize.api.notification.model; + +public enum NotificationStatus { + PENDING, + SENT, + FAILED, + READ, +} diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationTrigger.java b/src/main/java/app/teamwize/api/notification/model/NotificationTrigger.java new file mode 100644 index 0000000..40d5e6b --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/NotificationTrigger.java @@ -0,0 +1,17 @@ +package app.teamwize.api.notification.model; + +import app.teamwize.api.event.model.EventType; + +import java.util.List; + +public record NotificationTrigger( + Long id, + String name, + String title, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + NotificationTriggerReceptors receptors, + NotificationTriggerStatus status) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java new file mode 100644 index 0000000..963f33d --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java @@ -0,0 +1,9 @@ +package app.teamwize.api.notification.model; + + +public enum NotificationTriggerReceptors { + USER, + TEAM_ADMIN, + ORGANIZATION_ADMIN, + ALL_TEAM_MEMBERS, +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationTriggerStatus.java b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerStatus.java new file mode 100644 index 0000000..caa7f5b --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerStatus.java @@ -0,0 +1,7 @@ +package app.teamwize.api.notification.model; + +public enum NotificationTriggerStatus { + ENABLED, + DISABLED, + ARCHIVED; +} diff --git a/src/main/java/app/teamwize/api/notification/model/command/NotificationCreateCommand.java b/src/main/java/app/teamwize/api/notification/model/command/NotificationCreateCommand.java new file mode 100644 index 0000000..827154a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/command/NotificationCreateCommand.java @@ -0,0 +1,18 @@ +package app.teamwize.api.notification.model.command; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.user.domain.entity.User; + +import java.util.Map; + +public record NotificationCreateCommand( + String title, + Long triggerId, + Long eventId, + EventType event, + User user, + Map params, + NotificationChannel channel +) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/command/NotificationFilterCommand.java b/src/main/java/app/teamwize/api/notification/model/command/NotificationFilterCommand.java new file mode 100644 index 0000000..e37086c --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/command/NotificationFilterCommand.java @@ -0,0 +1,7 @@ +package app.teamwize.api.notification.model.command; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationStatus; + +public record NotificationFilterCommand(NotificationStatus status, EventType eventType, Long userId) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java new file mode 100644 index 0000000..054308a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java @@ -0,0 +1,16 @@ +package app.teamwize.api.notification.model.command; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; + +import java.util.List; + +public record NotificationTriggerCreateCommand( + String name, + String title, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + String receptors) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerUpdateCommand.java b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerUpdateCommand.java new file mode 100644 index 0000000..5fcac75 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerUpdateCommand.java @@ -0,0 +1,17 @@ +package app.teamwize.api.notification.model.command; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; + +import java.util.List; + +public record NotificationTriggerUpdateCommand( + String name, + String title, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + NotificationTriggerReceptors receptors) { +} diff --git a/src/main/java/app/teamwize/api/notification/model/entity/NotificationEntity.java b/src/main/java/app/teamwize/api/notification/model/entity/NotificationEntity.java new file mode 100644 index 0000000..08e59c9 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/entity/NotificationEntity.java @@ -0,0 +1,64 @@ +package app.teamwize.api.notification.model.entity; + +import app.teamwize.api.base.domain.entity.BaseAuditEntity; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationStatus; +import app.teamwize.api.organization.domain.entity.Organization; +import app.teamwize.api.user.domain.entity.User; +import io.hypersistence.utils.hibernate.type.array.StringArrayType; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Type; + +import java.time.Instant; +import java.util.Map; + +@Getter +@Setter +@Entity +@Table(name = "notifications") +public class NotificationEntity extends BaseAuditEntity { + @Id + @GeneratedValue(generator = "notification_id_seq_gen") + @SequenceGenerator(name = "notification_id_seq_gen", sequenceName = "notification_id_seq", allocationSize = 10) + private Long id; + + @ManyToOne + private NotificationTriggerEntity trigger; + + @Enumerated(EnumType.STRING) + private EventType eventType; + + private Long eventId; + + @ManyToOne + private Organization organization; + + private String title; + + private String textContent; + + private String htmlContent; + + @ManyToOne + private User user; + + @Type(JsonType.class) + private Map params; + + @Type(StringArrayType.class) + @Enumerated(EnumType.STRING) + private NotificationChannel[] channels; + + private Instant sentAt; + + @Enumerated(EnumType.STRING) + private NotificationStatus status; + + + @Type(JsonType.class) + private Map metadata; +} diff --git a/src/main/java/app/teamwize/api/notification/model/entity/NotificationTriggerEntity.java b/src/main/java/app/teamwize/api/notification/model/entity/NotificationTriggerEntity.java new file mode 100644 index 0000000..73ff7d1 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/entity/NotificationTriggerEntity.java @@ -0,0 +1,47 @@ +package app.teamwize.api.notification.model.entity; + +import app.teamwize.api.base.domain.entity.BaseAuditEntity; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.NotificationTriggerStatus; +import app.teamwize.api.organization.domain.entity.Organization; +import io.hypersistence.utils.hibernate.type.array.StringArrayType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.Type; + +@Getter +@Setter +@Entity +@Table(name = "notification_triggers") +public class NotificationTriggerEntity extends BaseAuditEntity { + @Id + @GeneratedValue(generator = "notification_trigger_id_seq_gen") + @SequenceGenerator(name = "notification_trigger_id_seq_gen", sequenceName = "notification_trigger_id_seq", allocationSize = 10) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private EventType eventType; + + @Type(StringArrayType.class) + @Enumerated(EnumType.STRING) + private NotificationChannel[] channels; + + @Enumerated(EnumType.STRING) + private NotificationTriggerReceptors receptors; + + @Enumerated(EnumType.STRING) + private NotificationTriggerStatus status; + + private String title; + + private String textTemplate; + private String htmlTemplate; + + @ManyToOne + private Organization organization; +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/model/event/NotificationCreatedEvent.java b/src/main/java/app/teamwize/api/notification/model/event/NotificationCreatedEvent.java new file mode 100644 index 0000000..e920c27 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/event/NotificationCreatedEvent.java @@ -0,0 +1,25 @@ +package app.teamwize.api.notification.model.event; + +import app.teamwize.api.event.model.EventPayload; +import app.teamwize.api.event.model.EventType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Map; + +@Schema(name = "NOTIFICATION_CREATED", description = "Notification created event") +public record NotificationCreatedEvent(NotificationEventPayload notification) implements EventPayload { + + @Override + public EventType name() { + return EventType.NOTIFICATION_CREATED; + } + + @Override + public Map payload() { + return Map.of( + "notification", notification + ); + } + + +} diff --git a/src/main/java/app/teamwize/api/notification/model/event/NotificationEventPayload.java b/src/main/java/app/teamwize/api/notification/model/event/NotificationEventPayload.java new file mode 100644 index 0000000..a0151f2 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/event/NotificationEventPayload.java @@ -0,0 +1,42 @@ +package app.teamwize.api.notification.model.event; + +import app.teamwize.api.auth.domain.event.OrganizationEventPayload; +import app.teamwize.api.auth.domain.event.UserEventPayload; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.NotificationChannel; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record NotificationEventPayload( + Long id, + String title, + UserEventPayload user, + NotificationTriggerEventPayload trigger, + String textContent, + String htmlContent, + EventType eventType, + OrganizationEventPayload organization, + Map params, + List channels, + Instant sentAt) { + + public NotificationEventPayload(Notification notification) { + this( + notification.id(), + notification.title(), + new UserEventPayload(notification.user()), + new NotificationTriggerEventPayload(notification.trigger()), + notification.textContent(), + notification.htmlContent(), + notification.eventType(), + new OrganizationEventPayload(notification.organization()), + notification.params(), + notification.channels(), + notification.sentAt() + ); + } + +} diff --git a/src/main/java/app/teamwize/api/notification/model/event/NotificationTriggerEventPayload.java b/src/main/java/app/teamwize/api/notification/model/event/NotificationTriggerEventPayload.java new file mode 100644 index 0000000..7cd2ff6 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/model/event/NotificationTriggerEventPayload.java @@ -0,0 +1,27 @@ +package app.teamwize.api.notification.model.event; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.NotificationTriggerStatus; + +import java.util.List; + +public record NotificationTriggerEventPayload( + Long id, + String title, + String name, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + NotificationTriggerReceptors receptors, + NotificationTriggerStatus status +) { + + public NotificationTriggerEventPayload(NotificationTrigger trigger) { + this(trigger.id(), trigger.name(), trigger.title(), trigger.textTemplate(), trigger.htmlTemplate(), trigger.eventType(), + trigger.channels(), trigger.receptors(), trigger.status()); + } +} diff --git a/src/main/java/app/teamwize/api/notification/repository/NotificationRepository.java b/src/main/java/app/teamwize/api/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..91dd425 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/repository/NotificationRepository.java @@ -0,0 +1,20 @@ +package app.teamwize.api.notification.repository; + +import app.teamwize.api.notification.model.entity.NotificationEntity; +import io.hypersistence.utils.spring.repository.BaseJpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotificationRepository extends BaseJpaRepository, JpaSpecificationExecutor { + List findByOrganizationId(Long organizationId); + + @EntityGraph(attributePaths = {"trigger", "user"}, type = EntityGraph.EntityGraphType.FETCH) + Page findAll(Specification spec, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/repository/NotificationSpecifications.java b/src/main/java/app/teamwize/api/notification/repository/NotificationSpecifications.java new file mode 100644 index 0000000..7c50610 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/repository/NotificationSpecifications.java @@ -0,0 +1,37 @@ +package app.teamwize.api.notification.repository; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationStatus; +import app.teamwize.api.notification.model.entity.NotificationEntity; +import org.springframework.data.jpa.domain.Specification; + +public class NotificationSpecifications { + + public static Specification withOrganizationId(Long organizationId) { + if (organizationId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("organization").get("id"), organizationId); + } + + public static Specification withUserId(Long userId) { + if (userId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("user").get("id"), userId); + } + + public static Specification withEventType(EventType eventType) { + if (eventType == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("status"), eventType); + } + + public static Specification withStatus(NotificationStatus status) { + if (status == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("status"), status); + } +} diff --git a/src/main/java/app/teamwize/api/notification/repository/NotificationTriggerRepository.java b/src/main/java/app/teamwize/api/notification/repository/NotificationTriggerRepository.java new file mode 100644 index 0000000..001fdbe --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/repository/NotificationTriggerRepository.java @@ -0,0 +1,21 @@ +package app.teamwize.api.notification.repository; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationTriggerStatus; +import app.teamwize.api.notification.model.entity.NotificationTriggerEntity; +import io.hypersistence.utils.spring.repository.BaseJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface NotificationTriggerRepository extends BaseJpaRepository { + List findByOrganizationIdAndEventTypeAndStatus(Long organizationId, EventType eventType, NotificationTriggerStatus status); + + Optional findByOrganizationIdAndId(Long organizationId, Long id); + + List findByOrganizationId(Long organizationId); + + Integer deleteByOrganizationIdAndId(Long userOrganizationId, Long id); +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/rest/controller/NotificationController.java b/src/main/java/app/teamwize/api/notification/rest/controller/NotificationController.java new file mode 100644 index 0000000..959e82d --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/controller/NotificationController.java @@ -0,0 +1,97 @@ +package app.teamwize.api.notification.rest.controller; + +import app.teamwize.api.auth.service.SecurityService; +import app.teamwize.api.base.domain.model.request.PaginationRequest; +import app.teamwize.api.base.domain.model.response.PagedResponse; +import app.teamwize.api.base.mapper.PagedResponseMapper; +import app.teamwize.api.notification.exception.NotificationTriggerNotFoundException; +import app.teamwize.api.notification.rest.mapper.NotificationRestMapper; +import app.teamwize.api.notification.rest.model.request.NotificationFilterRequest; +import app.teamwize.api.notification.rest.model.request.NotificationTriggerCreateRequest; +import app.teamwize.api.notification.rest.model.request.NotificationTriggerUpdateRequest; +import app.teamwize.api.notification.rest.model.response.NotificationResponse; +import app.teamwize.api.notification.rest.model.response.NotificationTriggerResponse; +import app.teamwize.api.notification.service.EventSchemaService; +import app.teamwize.api.notification.service.NotificationService; +import app.teamwize.api.notification.service.NotificationTriggerService; +import app.teamwize.api.organization.exception.OrganizationNotFoundException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + private final NotificationTriggerService notificationTriggerService; + private final SecurityService securityService; + private final NotificationRestMapper notificationRestMapper; + private final PagedResponseMapper pagedResponseMapper; + private final EventSchemaService eventSchemaService; + + + @GetMapping + public PagedResponse getNotifications(@ParameterObject @Valid NotificationFilterRequest request, + @ParameterObject @Valid PaginationRequest pagination) { + var result = notificationService.getNotifications(securityService.getUserOrganizationId(), + notificationRestMapper.toCommand(request), + pagination + ); + return pagedResponseMapper.toPagedResponse( + notificationRestMapper.toResponses(result.contents()), + result.pageNumber(), + result.pageSize(), + result.totalPages(), + result.totalContents() + ); + } + + @PostMapping("triggers") + public NotificationTriggerResponse createNotificationTrigger(@RequestBody NotificationTriggerCreateRequest request) throws OrganizationNotFoundException { + var result = notificationTriggerService.createNotificationTrigger(securityService.getUserOrganizationId(), notificationRestMapper.toCommand(request)); + return notificationRestMapper.toResponse(result); + } + + @GetMapping("triggers") + public List getNotificationTriggers() { + return notificationTriggerService.getNotificationTriggers(securityService.getUserOrganizationId()).stream().map( + notificationRestMapper::toResponse + ).toList(); + } + + + @PutMapping("/triggers/{id}") + public NotificationTriggerResponse updateTemplate(@PathVariable Long id, + @RequestBody NotificationTriggerUpdateRequest request) throws NotificationTriggerNotFoundException { + var result = notificationTriggerService.updateTrigger(securityService.getUserOrganizationId(), id, notificationRestMapper.toCommand(request)); + return notificationRestMapper.toResponse(result); + } + + @GetMapping("/triggers/{id}") + public NotificationTriggerResponse getTrigger(@PathVariable Long id) throws NotificationTriggerNotFoundException { + var trigger = notificationTriggerService.getNotificationTrigger(securityService.getUserOrganizationId(), id); + return notificationRestMapper.toResponse(trigger); + } + + + @DeleteMapping("/triggers/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteTrigger(@PathVariable Long id) throws NotificationTriggerNotFoundException { + notificationTriggerService.deleteTrigger(securityService.getUserOrganizationId(), id); + } + + @GetMapping("events") + public List getEvents() { + return eventSchemaService.getEventSchemas(); + } + + +} diff --git a/src/main/java/app/teamwize/api/notification/rest/mapper/NotificationRestMapper.java b/src/main/java/app/teamwize/api/notification/rest/mapper/NotificationRestMapper.java new file mode 100644 index 0000000..7084c54 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/mapper/NotificationRestMapper.java @@ -0,0 +1,30 @@ +package app.teamwize.api.notification.rest.mapper; + +import app.teamwize.api.base.config.DefaultMapperConfig; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.command.NotificationFilterCommand; +import app.teamwize.api.notification.model.command.NotificationTriggerCreateCommand; +import app.teamwize.api.notification.model.command.NotificationTriggerUpdateCommand; +import app.teamwize.api.notification.rest.model.request.NotificationFilterRequest; +import app.teamwize.api.notification.rest.model.request.NotificationTriggerCreateRequest; +import app.teamwize.api.notification.rest.model.request.NotificationTriggerUpdateRequest; +import app.teamwize.api.notification.rest.model.response.NotificationResponse; +import app.teamwize.api.notification.rest.model.response.NotificationTriggerResponse; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(config = DefaultMapperConfig.class) +public interface NotificationRestMapper { + NotificationFilterCommand toCommand(NotificationFilterRequest command); + + List toResponses(List contents); + + + NotificationTriggerCreateCommand toCommand(NotificationTriggerCreateRequest request); + + NotificationTriggerResponse toResponse(NotificationTrigger result); + + NotificationTriggerUpdateCommand toCommand(NotificationTriggerUpdateRequest request); +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationFilterRequest.java b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationFilterRequest.java new file mode 100644 index 0000000..f5f845e --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationFilterRequest.java @@ -0,0 +1,7 @@ +package app.teamwize.api.notification.rest.model.request; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationStatus; + +public record NotificationFilterRequest(NotificationStatus status, EventType eventType, Long userId) { +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerCreateRequest.java b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerCreateRequest.java new file mode 100644 index 0000000..88946c5 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerCreateRequest.java @@ -0,0 +1,16 @@ +package app.teamwize.api.notification.rest.model.request; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; + +import java.util.List; + +public record NotificationTriggerCreateRequest( + String name, + String title, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + String receptors) { +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerUpdateRequest.java b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerUpdateRequest.java new file mode 100644 index 0000000..3490db2 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/request/NotificationTriggerUpdateRequest.java @@ -0,0 +1,15 @@ +package app.teamwize.api.notification.rest.model.request; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; + +import java.util.List; + +public record NotificationTriggerUpdateRequest( + String name, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + String receptors) { +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationResponse.java b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationResponse.java new file mode 100644 index 0000000..e1ff45e --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationResponse.java @@ -0,0 +1,23 @@ +package app.teamwize.api.notification.rest.model.response; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationStatus; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record NotificationResponse( + Long id, + String title, + NotificationTriggerCompactResponse trigger, + String textContent, + String htmlContent, + EventType eventType, + Map params, + List channels, + Instant sentAt, + NotificationStatus status +) { +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerCompactResponse.java b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerCompactResponse.java new file mode 100644 index 0000000..f934b9a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerCompactResponse.java @@ -0,0 +1,12 @@ +package app.teamwize.api.notification.rest.model.response; + +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.NotificationTriggerStatus; + +public record NotificationTriggerCompactResponse( + Long id, + String title, + String name, + NotificationTriggerReceptors receptors, + NotificationTriggerStatus status) { +} diff --git a/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerResponse.java b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerResponse.java new file mode 100644 index 0000000..2473280 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/rest/model/response/NotificationTriggerResponse.java @@ -0,0 +1,20 @@ +package app.teamwize.api.notification.rest.model.response; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.NotificationTriggerStatus; + +import java.util.List; + +public record NotificationTriggerResponse( + Long id, + String title, + String name, + String textTemplate, + String htmlTemplate, + EventType eventType, + List channels, + NotificationTriggerReceptors receptors, + NotificationTriggerStatus status) { +} diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java b/src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java new file mode 100644 index 0000000..ea17c8a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java @@ -0,0 +1,58 @@ +package app.teamwize.api.notification.service; + +import app.teamwize.api.event.entity.EventEntity; +import app.teamwize.api.event.model.EventExitCode; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.event.service.handler.EventHandler; +import app.teamwize.api.notification.exception.NotificationSendFailureException; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.event.NotificationCreatedEvent; +import app.teamwize.api.notification.service.notifier.Notifier; +import app.teamwize.api.user.service.UserService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class NotificationCreatedEventHandler implements EventHandler { + + private final ObjectMapper objectMapper; + private final List notifiers; + private final UserService userService; + + @Override + public String name() { + return "NotificationSender"; + } + + @Override + public boolean accepts(EventType type) { + return type == EventType.NOTIFICATION_CREATED; + } + + @Override + public EventExecutionResult process(EventEntity eventEntity) { + try { + var notificationPayload = objectMapper.writeValueAsString(eventEntity.getParams()); + var event = objectMapper.readValue(notificationPayload, NotificationCreatedEvent.class); + + log.info("Notification payload: {}", event); + for (var notifier : notifiers) { + for (NotificationChannel channel : event.notification().channels()) { + if (notifier.accepts(channel)) { + notifier.notify(event.notification()); + } + } + } + return new EventExecutionResult(EventExitCode.SUCCESS, null); + } catch (JsonProcessingException | NotificationSendFailureException e) { + return new EventExecutionResult(EventExitCode.ERROR, null); + } + } +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java b/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java new file mode 100644 index 0000000..33de211 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java @@ -0,0 +1,149 @@ +package app.teamwize.api.notification.service; + +import app.teamwize.api.auth.domain.event.OrganizationCreatedEvent; +import app.teamwize.api.event.entity.EventEntity; +import app.teamwize.api.event.model.EventExitCode; +import app.teamwize.api.event.model.EventPayload; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.event.service.handler.EventHandler; +import app.teamwize.api.leave.model.event.LeaveCreatedEvent; +import app.teamwize.api.leave.model.event.LeaveStatusUpdatedEvent; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.command.NotificationCreateCommand; +import app.teamwize.api.notification.model.event.NotificationCreatedEvent; +import app.teamwize.api.user.domain.UserRole; +import app.teamwize.api.user.domain.entity.User; +import app.teamwize.api.user.domain.event.UserInvitedEvent; +import app.teamwize.api.user.exception.UserNotFoundException; +import app.teamwize.api.user.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Component +@Slf4j +@RequiredArgsConstructor +public class NotificationEventHandler implements EventHandler { + + private final NotificationTriggerService notificationTriggerService; + private final NotificationService notificationService; + private final UserService userService; + private final ObjectMapper objectMapper; + + @Override + public String name() { + return this.getClass().getSimpleName(); + } + + @Override + public boolean accepts(EventType type) { + return type != EventType.NOTIFICATION_CREATED; + } + + @Override + public EventExecutionResult process(EventEntity eventEntity) { + var triggers = notificationTriggerService.getNotificationTriggers( + eventEntity.getOrganization().getId(), + eventEntity.getType() + ); + var notifications = new ArrayList(); + try { + for (var trigger : triggers) { + // Then compile with handlebars for any dynamic values + var receptors = compileReceptors(eventEntity, trigger); + log.info("Processing notification for receptors: {} , event : {} , trigger : {}", receptors, eventEntity.getType(), trigger.id()); + for (var receptor : receptors) { + // try to compile the template + var notification = notificationService.createNotification(eventEntity.getOrganization().getId(), + new NotificationCreateCommand( + trigger.title(), + trigger.id(), + eventEntity.getId(), + eventEntity.getType(), + receptor, + eventEntity.getParams(), + trigger.channels().getFirst()) + ); + notifications.add(notification); + log.info("Notification created: {}", notification); + } + } + return new EventExecutionResult(EventExitCode.SUCCESS, + Map.of("triggers", triggers.size(), "notifications", notifications.size()) + ); + } catch (Exception e) { + log.error("Error while processing notification", e); + return new EventExecutionResult(EventExitCode.ERROR, Map.of("error", e.getMessage())); + } + } + + private T getEventPayload(EventEntity event, Class clazz) throws IOException { + var notificationPayload = objectMapper.writeValueAsString(event.getParams()); + return objectMapper.readValue(notificationPayload, clazz); + } + + private List compileReceptors(EventEntity event, NotificationTrigger trigger) throws IOException, UserNotFoundException { + if (trigger.receptors() == null) { + return List.of(); + } + var organizationId = event.getOrganization().getId(); + Long teamId; + Long userId; + switch (event.getType()) { + case USER_CREATED -> { + var payload = getEventPayload(event, UserInvitedEvent.class); + userId = payload.user().id(); + teamId = payload.user().teamId(); + } + case LEAVE_CREATED -> { + var payload = getEventPayload(event, LeaveCreatedEvent.class); + teamId = payload.user().teamId(); + userId = payload.user().id(); + } + case LEAVE_STATUS_UPDATED -> { + var payload = getEventPayload(event, LeaveStatusUpdatedEvent.class); + + teamId = payload.user().teamId(); + userId = payload.user().id(); + } + case NOTIFICATION_CREATED -> { + var payload = getEventPayload(event, NotificationCreatedEvent.class); + userId = payload.notification().user().id(); + teamId = payload.notification().user().teamId(); + } + case ORGANIZATION_CREATED -> { + var payload = getEventPayload(event, OrganizationCreatedEvent.class); + userId = payload.user().id(); + teamId = payload.user().teamId(); + } + default -> { + throw new IllegalStateException("Unexpected value: " + event.getType()); + } + } + + switch (trigger.receptors()) { + case USER -> { + var user = userService.getUser(organizationId, userId); + return List.of(user); + } + case ALL_TEAM_MEMBERS -> { + return userService.getUsersByTeam(organizationId, teamId, List.of(UserRole.EMPLOYEE, UserRole.TEAM_ADMIN)); + } + case ORGANIZATION_ADMIN -> { + return userService.getUsersByRole(event.getOrganization().getId(), UserRole.ORGANIZATION_ADMIN); + } + case TEAM_ADMIN -> { + return userService.getUsersByRole(event.getOrganization().getId(), UserRole.TEAM_ADMIN); + } + } + return Collections.emptyList(); + } +} diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationService.java b/src/main/java/app/teamwize/api/notification/service/NotificationService.java new file mode 100644 index 0000000..4ca9bfb --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/NotificationService.java @@ -0,0 +1,123 @@ +package app.teamwize.api.notification.service; + +import app.teamwize.api.base.domain.model.Paged; +import app.teamwize.api.base.domain.model.request.PaginationRequest; +import app.teamwize.api.event.service.EventService; +import app.teamwize.api.notification.exception.NotificationTemplateCompileException; +import app.teamwize.api.notification.exception.NotificationTriggerNotFoundException; +import app.teamwize.api.notification.mapper.NotificationMapper; +import app.teamwize.api.notification.mapper.NotificationTriggerMapper; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationStatus; +import app.teamwize.api.notification.model.command.NotificationCreateCommand; +import app.teamwize.api.notification.model.command.NotificationFilterCommand; +import app.teamwize.api.notification.model.event.NotificationCreatedEvent; +import app.teamwize.api.notification.model.event.NotificationEventPayload; +import app.teamwize.api.notification.repository.NotificationRepository; +import app.teamwize.api.organization.exception.OrganizationNotFoundException; +import app.teamwize.api.organization.service.OrganizationService; +import app.teamwize.api.user.service.UserService; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.io.StringTemplateSource; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +import static app.teamwize.api.notification.repository.NotificationSpecifications.*; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final NotificationRepository notificationRepository; + private final NotificationMapper notificationMapper; + private final NotificationTriggerService triggerService; + private final NotificationTriggerMapper notificationTriggerMapper; + private final OrganizationService organizationService; + private final UserService userService; + @Lazy + private final EventService eventService; + private final Handlebars handlebars; + + @Transactional + public Notification createNotification(Long organizationId, NotificationCreateCommand command) + throws NotificationTriggerNotFoundException, OrganizationNotFoundException, NotificationTemplateCompileException { + var entity = notificationMapper.toEntity(command); + + // Set template and trigger from their IDs + + var trigger = triggerService.getNotificationTrigger(organizationId, command.triggerId()); + var organization = organizationService.getOrganization(organizationId); + + try { + var textContent = handlebars.compile(new StringTemplateSource("notification", trigger.textTemplate())).apply(entity.getParams()); + var htmlContent = handlebars.compile(new StringTemplateSource("notification", trigger.htmlTemplate())).apply(entity.getParams()); + var title = handlebars.compile(new StringTemplateSource("title", trigger.title())).apply(entity.getParams()); + entity + .setTitle(title) + .setHtmlContent(htmlContent) + .setTextContent(textContent); + } catch (Exception e) { + throw new NotificationTemplateCompileException("Error compiling template : " + e.getMessage()); + } + entity + .setSentAt(Instant.now()) + .setChannels(trigger.channels() == null ? null : trigger.channels().toArray(new NotificationChannel[0])) + .setEventType(command.event()) + .setEventId(command.eventId()) + .setStatus(NotificationStatus.PENDING) + .setTrigger(notificationTriggerMapper.toEntity(trigger)) + .setUser(command.user()) + .setOrganization(organization); + + entity = notificationRepository.persist(entity); + var model = notificationMapper.toModel(entity); + eventService.emmit(organizationId, new NotificationCreatedEvent(new NotificationEventPayload(model))); + return model; + } + + @Transactional(readOnly = true) + public Notification getNotification(Long id) { + var entity = notificationRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Notification not found with id: " + id)); + return notificationMapper.toModel(entity); + } + + + @Transactional(readOnly = true) + public List getNotificationsByOrganization(Long organizationId) { + var entities = notificationRepository.findByOrganizationId(organizationId); + return notificationMapper.toModels(entities); + } + + @Transactional(readOnly = true) + public Paged getNotifications( + Long organizationId, + NotificationFilterCommand command, + PaginationRequest pagination) { + var sort = Sort.by("id").descending(); + var pageRequest = PageRequest.of(pagination.getPageNumber(), pagination.getPageSize(), sort); + + var specs = Specification.where(withOrganizationId(organizationId)) + .and(withUserId(command.userId())) + .and(withStatus(command.status())) + .and(withEventType(command.eventType())); + + var page = notificationRepository.findAll(specs, pageRequest) + .map(notificationMapper::toModel); + + return new Paged<>( + page.getContent(), + pagination.getPageNumber(), + pagination.getPageSize(), + page.getTotalElements() + ); + } +} diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationTriggerService.java b/src/main/java/app/teamwize/api/notification/service/NotificationTriggerService.java new file mode 100644 index 0000000..b714a0a --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/NotificationTriggerService.java @@ -0,0 +1,83 @@ +package app.teamwize.api.notification.service; + +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.notification.exception.NotificationTriggerNotFoundException; +import app.teamwize.api.notification.mapper.NotificationTriggerMapper; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.NotificationTriggerStatus; +import app.teamwize.api.notification.model.command.NotificationTriggerCreateCommand; +import app.teamwize.api.notification.model.command.NotificationTriggerUpdateCommand; +import app.teamwize.api.notification.repository.NotificationTriggerRepository; +import app.teamwize.api.organization.exception.OrganizationNotFoundException; +import app.teamwize.api.organization.service.OrganizationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationTriggerService { + + private final NotificationTriggerRepository notificationTriggerRepository; + private final NotificationTriggerMapper notificationTriggerMapper; + private final OrganizationService organizationService; + + @Transactional + public NotificationTrigger createNotificationTrigger(Long organizationId, NotificationTriggerCreateCommand notificationTrigger) throws OrganizationNotFoundException { + var organization = organizationService.getOrganization(organizationId); + var entity = notificationTriggerMapper.toEntity(notificationTrigger) + .setTitle(notificationTrigger.title()) + .setStatus(NotificationTriggerStatus.ENABLED) + .setOrganization(organization); + entity = notificationTriggerRepository.persist(entity); + return notificationTriggerMapper.toModel(entity); + } + + public List getNotificationTriggers(Long organizationId, EventType eventType) { + var triggers = notificationTriggerRepository.findByOrganizationIdAndEventTypeAndStatus( + organizationId, + eventType, + NotificationTriggerStatus.ENABLED + ); + return notificationTriggerMapper.toModels(triggers); + } + + public List getNotificationTriggers(Long organizationId) { + var triggers = notificationTriggerRepository.findByOrganizationId( + organizationId + ); + return notificationTriggerMapper.toModels(triggers); + } + + public NotificationTrigger getNotificationTrigger(Long organizationId, Long id) throws NotificationTriggerNotFoundException { + return notificationTriggerRepository.findByOrganizationIdAndId(organizationId, id) + .map(notificationTriggerMapper::toModel) + .orElseThrow(() -> new NotificationTriggerNotFoundException("Notification trigger not found")); + } + + public void deleteTrigger(Long organizationId, Long id) throws NotificationTriggerNotFoundException { + var trigger = notificationTriggerRepository.findByOrganizationIdAndId(organizationId, id) + .orElseThrow(() -> new NotificationTriggerNotFoundException("Notification trigger not found")); + + trigger.setStatus(NotificationTriggerStatus.ARCHIVED); + notificationTriggerRepository.merge(trigger); + } + + public NotificationTrigger updateTrigger(Long organizationId, Long id, NotificationTriggerUpdateCommand request) throws NotificationTriggerNotFoundException { + var trigger = notificationTriggerRepository.findByOrganizationIdAndId(organizationId, id) + .orElseThrow(() -> new NotificationTriggerNotFoundException("Notification trigger not found")); + + + trigger.setChannels(request.channels() == null ? null : request.channels().toArray(new NotificationChannel[0])) + .setEventType(request.eventType()) + .setHtmlTemplate(request.htmlTemplate()) + .setTextTemplate(request.textTemplate()) + .setReceptors(request.receptors()); + + trigger = notificationTriggerRepository.merge(trigger); + return notificationTriggerMapper.toModel(trigger); + } +} diff --git a/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java b/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java new file mode 100644 index 0000000..3ab33f7 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java @@ -0,0 +1,62 @@ +package app.teamwize.api.notification.service.notifier; + +import app.teamwize.api.notification.exception.NotificationSendFailureException; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.event.NotificationEventPayload; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.io.StringTemplateSource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import java.nio.charset.Charset; +import java.time.LocalDate; +import java.util.Map; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailNotifier implements Notifier { + + private final JavaMailSender mailSender; + private final Handlebars templateEngine; + @Value("classpath:templates/email/layout.hbs") + private Resource emailTemplate; + + @Override + public boolean accepts(NotificationChannel channel) { + return channel == NotificationChannel.EMAIL; + } + + @Override + public void notify(NotificationEventPayload event) throws NotificationSendFailureException { + try { + var message = mailSender.createMimeMessage(); + var helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(event.user().email()); + helper.setSubject(event.title()); + + var context = Map.of( + "title", event.id(), + "body", event.htmlContent(), + "year", LocalDate.now().getYear(), + "unsubscribeUrl", "https://teamwize.app/unsubscribe?email=" + event.user().email(), + "companyName", "TeamPilot" + ); + + var html = templateEngine.compile(new StringTemplateSource("email", emailTemplate.getContentAsString(Charset.defaultCharset()))).apply(context); + + helper.setText(html, true); // Set to true for HTML + + mailSender.send(message); + log.info("Email sent to {}", event.user().email()); + } catch (Exception e) { + throw new NotificationSendFailureException("Failed to send email notification: " + e.getMessage()); + } + } +} diff --git a/src/main/java/app/teamwize/api/notification/service/notifier/Notifier.java b/src/main/java/app/teamwize/api/notification/service/notifier/Notifier.java new file mode 100644 index 0000000..82d99dc --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/service/notifier/Notifier.java @@ -0,0 +1,12 @@ +package app.teamwize.api.notification.service.notifier; + +import app.teamwize.api.notification.exception.NotificationSendFailureException; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.event.NotificationEventPayload; + + +public interface Notifier { + boolean accepts(NotificationChannel channel); + + void notify(NotificationEventPayload event) throws NotificationSendFailureException; +} diff --git a/src/main/java/app/teamwize/api/user/domain/event/UserInvitedEvent.java b/src/main/java/app/teamwize/api/user/domain/event/UserInvitedEvent.java index f4318a5..16cc0e2 100644 --- a/src/main/java/app/teamwize/api/user/domain/event/UserInvitedEvent.java +++ b/src/main/java/app/teamwize/api/user/domain/event/UserInvitedEvent.java @@ -4,10 +4,11 @@ import app.teamwize.api.auth.domain.event.UserEventPayload; import app.teamwize.api.event.model.EventPayload; import app.teamwize.api.event.model.EventType; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.Map; - +@Schema(name = "USER_CREATED", description = "User created event") public record UserInvitedEvent(UserEventPayload user, OrganizationEventPayload organization) implements EventPayload { @Override public EventType name() { diff --git a/src/main/java/app/teamwize/api/user/listener/UserEventListener.java b/src/main/java/app/teamwize/api/user/listener/UserEventListener.java deleted file mode 100644 index 010cb6c..0000000 --- a/src/main/java/app/teamwize/api/user/listener/UserEventListener.java +++ /dev/null @@ -1,31 +0,0 @@ -package app.teamwize.api.user.listener; - -import app.teamwize.api.user.domain.event.UserInvitedEvent; -import app.teamwize.api.user.domain.event.UserPasswordResetEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class UserEventListener { - - - -// @Value("${cloud.aws.sqs.new-user-invited-queue}") -// private String userInvitedQueue; -// @Value("${cloud.aws.sqs.user-password-reset-queue}") -// private String userPasswordResetQueue; - - @EventListener - public void handleUserInvitedEvent(UserInvitedEvent event) { -// sqsUtil.sendMessage(userInvitedQueue, event); - } - - @EventListener - public void handleUserPasswordResetEvent(UserPasswordResetEvent event) { -// sqsUtil.sendMessage(userPasswordResetQueue, event); - } -} diff --git a/src/main/java/app/teamwize/api/user/listener/UserEventSQSListener.java b/src/main/java/app/teamwize/api/user/listener/UserEventSQSListener.java deleted file mode 100644 index 4cdbce9..0000000 --- a/src/main/java/app/teamwize/api/user/listener/UserEventSQSListener.java +++ /dev/null @@ -1,39 +0,0 @@ -package app.teamwize.api.user.listener; - - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - - - -@Slf4j -@Component -@Profile("!it") -@RequiredArgsConstructor -public class UserEventSQSListener { - - -// @SqsListener(value = "${cloud.aws.sqs.new-user-invited-queue}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) -// public void handleUserInvitedEvent(UserInvitedEvent event) -// throws UserNotFoundException, MessagingException, UserAlreadyActivatedException { -// try { -// activationService.sendActivationEmail(event.getSiteId(), event.getUserId(), event.getLanguage()); -// } catch (Exception ex) { -// log.error("Error in sending activation email for : " + event.toString(), ex); -// throw ex; -// } -// } -// -// @SqsListener(value = "${cloud.aws.sqs.user-password-reset-queue}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) -// public void handleUserPasswordResetEvent(UserInvitedEvent event) throws MessagingException { -// try { -// resetPasswordService.sendResetPasswordEmail(event.getSiteId(), event.getUserId(), event.getEmail(), -// event.getLanguage()); -// } catch (MessagingException ex) { -// log.error("Error in sending reset password email for : " + event.toString(), ex); -// throw ex; -// } -// } -} diff --git a/src/main/java/app/teamwize/api/user/repository/UserRepository.java b/src/main/java/app/teamwize/api/user/repository/UserRepository.java index cfc5582..fb39140 100644 --- a/src/main/java/app/teamwize/api/user/repository/UserRepository.java +++ b/src/main/java/app/teamwize/api/user/repository/UserRepository.java @@ -1,5 +1,6 @@ package app.teamwize.api.user.repository; +import app.teamwize.api.user.domain.UserRole; import app.teamwize.api.user.domain.entity.User; import io.hypersistence.utils.spring.repository.BaseJpaRepository; import org.springframework.data.domain.Page; @@ -25,4 +26,13 @@ public interface UserRepository extends BaseJpaRepository, JpaSpecif @EntityGraph(attributePaths = {"organization", "team", "avatar"}, type = EntityGraph.EntityGraphType.FETCH) Page findAll(Specification spec, Pageable pageable); + + @EntityGraph(attributePaths = {"organization", "team", "avatar"}, type = EntityGraph.EntityGraphType.FETCH) + Optional findByOrganizationIdAndEmail(Long organizationId, String email); + + @EntityGraph(attributePaths = {"organization", "team", "avatar"}, type = EntityGraph.EntityGraphType.FETCH) + List findByOrganizationIdAndTeamIdAndRoleIsIn(Long organizationId, Long teamId, List roles); + + @EntityGraph(attributePaths = {"organization", "team", "avatar"}, type = EntityGraph.EntityGraphType.FETCH) + List findByOrganizationIdAndRole(Long organizationId, UserRole role); } diff --git a/src/main/java/app/teamwize/api/user/service/UserService.java b/src/main/java/app/teamwize/api/user/service/UserService.java index 02a9e61..787a3e6 100644 --- a/src/main/java/app/teamwize/api/user/service/UserService.java +++ b/src/main/java/app/teamwize/api/user/service/UserService.java @@ -33,6 +33,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + import static app.teamwize.api.user.repository.UserSpecifications.*; @Slf4j @@ -58,6 +60,11 @@ public User getUser(long organizationId, long userId) throws UserNotFoundExcepti return getById(organizationId, userId); } + public User getUserByEmail(Long organizationId, String email) throws UserNotFoundException { + return userRepository.findByOrganizationIdAndEmail(organizationId, email) + .orElseThrow(() -> new UserNotFoundException(email)); + } + public User getUserByEmail(String email) throws UserNotFoundException { return userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException(email)); @@ -194,6 +201,14 @@ public void resetPassword(Long organizationId, Long userId, String password) thr userRepository.update(user); } + public List getUsersByTeam(Long organizationId, Long teamId, List roles) { + return userRepository.findByOrganizationIdAndTeamIdAndRoleIsIn(organizationId, teamId, roles); + } + + public List getUsersByRole(Long organizationId, UserRole role) { + return userRepository.findByOrganizationIdAndRole(organizationId, role); + } + private User getById(Long organizationId, Long userId) throws UserNotFoundException { return userRepository.findByOrganizationIdAndId(organizationId, userId) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId)); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index fd2759a..47fd249 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,4 +2,20 @@ spring: jpa: properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: false + mail: + host: smtp.zoho.com + port: 587 + password: 431069562Oo! + username: me@mohsen.work + properties: + mail: + smtp: + auth: true + ssl: + trust: smtp.zoho.com + from: me@mohsen.work + socketFactory: + port: 587 + starttls: + enable: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 592c317..c218b2d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,17 @@ app: secret: THIS_IS_THE_DEFAULT_RANDOM_STRING expiration-in-ms: '604800000' spring: + mail: + host: ${SMTP_HOST} + port: ${SMTP_PORT} + username: ${SMTP_USERNAME} + password: ${SMTP_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jpa: properties: hibernate: @@ -41,7 +52,7 @@ spring: ddl-auto: validate naming: implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl - show-sql: true + show-sql: false open-in-view: false profiles: active: dev diff --git a/src/main/resources/templates/email/layout.hbs b/src/main/resources/templates/email/layout.hbs new file mode 100644 index 0000000..c40a86a --- /dev/null +++ b/src/main/resources/templates/email/layout.hbs @@ -0,0 +1,51 @@ + + + + + + {{title}} + + + + + + +
+ + + + + + + + + + + + + + + +
+ Logo +
+ + + + +
+ {{{body}}} +
+
+

© {{year}} {{companyName}}. All rights reserved.

+ {{#if unsubscribeUrl}} +

+ Unsubscribe +

+ {{/if}} +
+
+ + \ No newline at end of file From 70762e0aa4aab495e902d59fefe75f957520c541 Mon Sep 17 00:00:00 2001 From: Mohsen Karimi Date: Sun, 9 Mar 2025 18:12:06 +0100 Subject: [PATCH 2/3] feat: implement default notification triggers for leave requests and updates Signed-off-by: Mohsen Karimi --- .../app/teamwize/api/event/model/Event.java | 3 + .../teamwize/api/event/model/EventType.java | 31 ++- .../api/event/service/EventService.java | 17 +- .../event/service/handler/EventHandler.java | 5 +- .../config/NotificationConfig.java | 9 + .../config/NotificationConfigModel.java | 13 + ...faultNotificationTriggersEventHandler.java | 98 +++++++ .../NotificationCreatedEventHandler.java | 18 +- .../listener/NotificationEventHandler.java | 249 ++++++++++++++++++ .../model/NotificationTriggerReceptors.java | 1 + .../NotificationTriggerCreateCommand.java | 3 +- .../service/NotificationEventHandler.java | 149 ----------- .../service/NotificationService.java | 4 + .../service/notifier/EmailNotifier.java | 27 +- src/main/resources/application.yml | 7 + .../email/default-leave-created-reviewer.html | 44 ++++ .../email/default-leave-created-reviewer.txt | 33 +++ .../email/default-leave-created-user.html | 56 ++++ .../email/default-leave-created-user.txt | 19 ++ .../email/default-leave-updated-user.html | 43 +++ .../email/default-leave-updated-user.txt | 35 +++ src/main/resources/templates/email/layout.hbs | 51 ---- .../resources/templates/email/layout.html | 83 ++++++ 23 files changed, 757 insertions(+), 241 deletions(-) create mode 100644 src/main/java/app/teamwize/api/notification/config/NotificationConfig.java create mode 100644 src/main/java/app/teamwize/api/notification/config/NotificationConfigModel.java create mode 100644 src/main/java/app/teamwize/api/notification/listener/CreateDefaultNotificationTriggersEventHandler.java rename src/main/java/app/teamwize/api/notification/{service => listener}/NotificationCreatedEventHandler.java (72%) create mode 100644 src/main/java/app/teamwize/api/notification/listener/NotificationEventHandler.java delete mode 100644 src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java create mode 100644 src/main/resources/templates/email/default-leave-created-reviewer.html create mode 100644 src/main/resources/templates/email/default-leave-created-reviewer.txt create mode 100644 src/main/resources/templates/email/default-leave-created-user.html create mode 100644 src/main/resources/templates/email/default-leave-created-user.txt create mode 100644 src/main/resources/templates/email/default-leave-updated-user.html create mode 100644 src/main/resources/templates/email/default-leave-updated-user.txt delete mode 100644 src/main/resources/templates/email/layout.hbs create mode 100644 src/main/resources/templates/email/layout.html diff --git a/src/main/java/app/teamwize/api/event/model/Event.java b/src/main/java/app/teamwize/api/event/model/Event.java index e5d691e..3dd3a05 100644 --- a/src/main/java/app/teamwize/api/event/model/Event.java +++ b/src/main/java/app/teamwize/api/event/model/Event.java @@ -1,11 +1,14 @@ package app.teamwize.api.event.model; +import app.teamwize.api.organization.domain.entity.Organization; + import java.time.Instant; import java.util.Map; public record Event( Long id, EventType type, + Organization organization, Map params, EventStatus status, Byte maxAttempts, diff --git a/src/main/java/app/teamwize/api/event/model/EventType.java b/src/main/java/app/teamwize/api/event/model/EventType.java index 8dbb76b..dab4f01 100644 --- a/src/main/java/app/teamwize/api/event/model/EventType.java +++ b/src/main/java/app/teamwize/api/event/model/EventType.java @@ -1,10 +1,29 @@ package app.teamwize.api.event.model; +import app.teamwize.api.auth.domain.event.OrganizationCreatedEvent; +import app.teamwize.api.leave.model.event.LeaveCreatedEvent; +import app.teamwize.api.leave.model.event.LeaveStatusUpdatedEvent; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.event.NotificationCreatedEvent; +import app.teamwize.api.team.domain.event.TeamCreatedEvent; +import app.teamwize.api.user.domain.event.UserInvitedEvent; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static app.teamwize.api.notification.model.NotificationTriggerReceptors.*; + +@RequiredArgsConstructor +@Getter public enum EventType { - ORGANIZATION_CREATED, - USER_CREATED, - LEAVE_CREATED, - LEAVE_STATUS_UPDATED, - TEAM_CREATED, - NOTIFICATION_CREATED, + ORGANIZATION_CREATED(OrganizationCreatedEvent.class, List.of(USER, ORGANIZATION_ADMIN)), + USER_CREATED(UserInvitedEvent.class, List.of(USER, TEAM_ADMIN, ORGANIZATION_ADMIN)), + LEAVE_CREATED(LeaveCreatedEvent.class, List.of(USER, TEAM_ADMIN, ORGANIZATION_ADMIN)), + LEAVE_STATUS_UPDATED(LeaveStatusUpdatedEvent.class, List.of(USER, TEAM_ADMIN, ORGANIZATION_ADMIN)), + TEAM_CREATED(TeamCreatedEvent.class, List.of(USER, TEAM_ADMIN, ORGANIZATION_ADMIN, ALL_TEAM_MEMBERS)), + NOTIFICATION_CREATED(NotificationCreatedEvent.class, List.of(USER, TEAM_ADMIN, ORGANIZATION_ADMIN, ALL_TEAM_MEMBERS)); + + private final Class payloadType; + private final List supportedReceptors; } diff --git a/src/main/java/app/teamwize/api/event/service/EventService.java b/src/main/java/app/teamwize/api/event/service/EventService.java index 1aed1e9..8e59be9 100644 --- a/src/main/java/app/teamwize/api/event/service/EventService.java +++ b/src/main/java/app/teamwize/api/event/service/EventService.java @@ -59,20 +59,17 @@ public Event emmit(Long organizationId, EventPayload eventPayload) { return emmit(organizationId, eventPayload.name(), eventPayload.payload(), (byte) 3, Instant.now()); } - - // Maybe it's better to try forever to execute an event - // There is no need to have exitCode for events they are not jobs - @Transactional - @Scheduled(fixedDelay = 10_000) + @Scheduled(fixedDelay = 1_000) public void processEvents() { var pendingEvents = eventRepository.findByStatus(EventStatus.PENDING); for (var pendingEvent : pendingEvents) { - for (var execution : pendingEvent.getExecutions()) { + var pendingExecutions = pendingEvent.getExecutions().stream().filter(execution -> execution.getStatus() == EventExecutionStatus.PENDING || execution.getStatus() == EventExecutionStatus.RETRYING).toList(); + for (var execution : pendingExecutions) { var handlerOptional = eventHandlers.stream().filter(eventHandler -> eventHandler.name().equals(execution.getHandler())).findFirst(); if (handlerOptional.isEmpty()) continue; var handler = handlerOptional.get(); - var executionResult = handler.process(pendingEvent); + var executionResult = handler.process(eventMapper.toEvent(pendingEvent)); if (executionResult.exitCode() == EventExitCode.SUCCESS) { execution.setStatus(EventExecutionStatus.FINISHED); } else { @@ -88,7 +85,11 @@ public void processEvents() { } executionRepository.update(execution); } - pendingEvent.setStatus(EventStatus.FINISHED); + if (pendingExecutions.stream().allMatch(execution -> execution.getStatus() == EventExecutionStatus.FINISHED)) { + pendingEvent.setStatus(EventStatus.FINISHED); + } else { + pendingEvent.setStatus(EventStatus.PENDING); + } } eventRepository.updateAll(pendingEvents); } diff --git a/src/main/java/app/teamwize/api/event/service/handler/EventHandler.java b/src/main/java/app/teamwize/api/event/service/handler/EventHandler.java index d0344f2..729bad8 100644 --- a/src/main/java/app/teamwize/api/event/service/handler/EventHandler.java +++ b/src/main/java/app/teamwize/api/event/service/handler/EventHandler.java @@ -1,6 +1,6 @@ package app.teamwize.api.event.service.handler; -import app.teamwize.api.event.entity.EventEntity; +import app.teamwize.api.event.model.Event; import app.teamwize.api.event.model.EventExitCode; import app.teamwize.api.event.model.EventType; @@ -11,8 +11,7 @@ public interface EventHandler { boolean accepts(EventType type); - EventExecutionResult process(EventEntity eventEntity); - + EventExecutionResult process(Event eventEntity); record EventExecutionResult(EventExitCode exitCode, Map metadata) { } diff --git a/src/main/java/app/teamwize/api/notification/config/NotificationConfig.java b/src/main/java/app/teamwize/api/notification/config/NotificationConfig.java new file mode 100644 index 0000000..b3991ef --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/config/NotificationConfig.java @@ -0,0 +1,9 @@ +package app.teamwize.api.notification.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(NotificationConfigModel.class) +public class NotificationConfig { +} diff --git a/src/main/java/app/teamwize/api/notification/config/NotificationConfigModel.java b/src/main/java/app/teamwize/api/notification/config/NotificationConfigModel.java new file mode 100644 index 0000000..6411405 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/config/NotificationConfigModel.java @@ -0,0 +1,13 @@ +package app.teamwize.api.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + + +@ConfigurationProperties(prefix = "app.notification") +public record NotificationConfigModel(EmailConfigModel email) { + public record EmailConfigModel(String from, String companyName, String unsubscribeUrl, Resource baseTemplate, + String baseUrl) { + } +} + diff --git a/src/main/java/app/teamwize/api/notification/listener/CreateDefaultNotificationTriggersEventHandler.java b/src/main/java/app/teamwize/api/notification/listener/CreateDefaultNotificationTriggersEventHandler.java new file mode 100644 index 0000000..38d8a92 --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/listener/CreateDefaultNotificationTriggersEventHandler.java @@ -0,0 +1,98 @@ +package app.teamwize.api.notification.listener; + +import app.teamwize.api.event.model.Event; +import app.teamwize.api.event.model.EventExitCode; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.event.service.handler.EventHandler; +import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; +import app.teamwize.api.notification.model.command.NotificationTriggerCreateCommand; +import app.teamwize.api.notification.service.NotificationTriggerService; +import app.teamwize.api.organization.exception.OrganizationNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class CreateDefaultNotificationTriggersEventHandler implements EventHandler { + + static final String DEFAULT_LEAVE_CREATE_USER_NOTIFICATION_TITLE = "Your Leave Request #{{leave.id}} Has Been Created"; + static final String DEFAULT_LEAVE_CREATE_USER_NOTIFICATION_NAME = "Notify user when leave is created"; + static final String DEFAULT_LEAVE_CREATE_REVIEWER_NOTIFICATION_TITLE = "New Leave Request - {{user.firstName}} {{user.lastName}}"; + static final String DEFAULT_LEAVE_CREATE_REVIEWER_NOTIFICATION_NAME = "Notify reviewers when leave is created"; + static final String DEFAULT_LEAVE_UPDATE_USER_NOTIFICATION_TITLE = "Update on Your Leave Request #{{leave.id}}"; + static final String DEFAULT_LEAVE_UPDATE_USER_NOTIFICATION_NAME = "Notify user when leave is updated"; + private final NotificationTriggerService notificationTriggerService; + @Value("classpath:/templates/email/default-leave-created-reviewer.html") + private Resource defaultLeaveCreatedReviewerTemplateHTML; + @Value("classpath:/templates/email/default-leave-created-reviewer.txt") + private Resource defaultLeaveCreatedReviewerTemplateTXT; + @Value("classpath:/templates/email/default-leave-updated-user.html") + private Resource defaultLeaveUpdatedUserTemplateHTML; + @Value("classpath:/templates/email/default-leave-updated-user.txt") + private Resource defaultLeaveUpdatedUserTemplateTXT; + @Value("classpath:/templates/email/default-leave-created-user.html") + private Resource defaultLeaveCreatedUserTemplateHTML; + @Value("classpath:/templates/email/default-leave-created-user.txt") + private Resource defaultLeaveCreatedUserTemplateTXT; + + @Override + public String name() { + return "DefaultNotificationTriggers"; + } + + @Override + public boolean accepts(EventType type) { + return type == EventType.ORGANIZATION_CREATED; + } + + @Override + public EventExecutionResult process(Event event) { + try { + // sending notification to user when leave is created + notificationTriggerService.createNotificationTrigger(event.organization().getId(), new NotificationTriggerCreateCommand( + DEFAULT_LEAVE_CREATE_USER_NOTIFICATION_NAME, + DEFAULT_LEAVE_CREATE_USER_NOTIFICATION_TITLE, + defaultLeaveCreatedUserTemplateTXT.getContentAsString(Charset.defaultCharset()), + defaultLeaveCreatedUserTemplateHTML.getContentAsString(Charset.defaultCharset()), + EventType.LEAVE_CREATED, + List.of(NotificationChannel.EMAIL), + NotificationTriggerReceptors.USER + )); + + // sending notification to user when leave is created + notificationTriggerService.createNotificationTrigger(event.organization().getId(), new NotificationTriggerCreateCommand( + DEFAULT_LEAVE_CREATE_REVIEWER_NOTIFICATION_NAME, + DEFAULT_LEAVE_CREATE_REVIEWER_NOTIFICATION_TITLE, + defaultLeaveCreatedReviewerTemplateTXT.getContentAsString(Charset.defaultCharset()), + defaultLeaveCreatedReviewerTemplateHTML.getContentAsString(Charset.defaultCharset()), + EventType.LEAVE_CREATED, + List.of(NotificationChannel.EMAIL), + NotificationTriggerReceptors.REVIEWERS + )); + + // sending notification to user when leave is updated + notificationTriggerService.createNotificationTrigger(event.organization().getId(), new NotificationTriggerCreateCommand( + DEFAULT_LEAVE_UPDATE_USER_NOTIFICATION_NAME, + DEFAULT_LEAVE_UPDATE_USER_NOTIFICATION_TITLE, + defaultLeaveUpdatedUserTemplateTXT.getContentAsString(Charset.defaultCharset()), + defaultLeaveUpdatedUserTemplateHTML.getContentAsString(Charset.defaultCharset()), + EventType.LEAVE_STATUS_UPDATED, + List.of(NotificationChannel.EMAIL), + NotificationTriggerReceptors.USER + )); + + return new EventExecutionResult(EventExitCode.SUCCESS, null); + } catch (OrganizationNotFoundException | IOException e) { + return new EventExecutionResult(EventExitCode.ERROR, null); + } + } +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java b/src/main/java/app/teamwize/api/notification/listener/NotificationCreatedEventHandler.java similarity index 72% rename from src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java rename to src/main/java/app/teamwize/api/notification/listener/NotificationCreatedEventHandler.java index ea17c8a..a1e31ec 100644 --- a/src/main/java/app/teamwize/api/notification/service/NotificationCreatedEventHandler.java +++ b/src/main/java/app/teamwize/api/notification/listener/NotificationCreatedEventHandler.java @@ -1,6 +1,6 @@ -package app.teamwize.api.notification.service; +package app.teamwize.api.notification.listener; -import app.teamwize.api.event.entity.EventEntity; +import app.teamwize.api.event.model.Event; import app.teamwize.api.event.model.EventExitCode; import app.teamwize.api.event.model.EventType; import app.teamwize.api.event.service.handler.EventHandler; @@ -8,7 +8,6 @@ import app.teamwize.api.notification.model.NotificationChannel; import app.teamwize.api.notification.model.event.NotificationCreatedEvent; import app.teamwize.api.notification.service.notifier.Notifier; -import app.teamwize.api.user.service.UserService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -24,7 +23,6 @@ public class NotificationCreatedEventHandler implements EventHandler { private final ObjectMapper objectMapper; private final List notifiers; - private final UserService userService; @Override public String name() { @@ -37,16 +35,16 @@ public boolean accepts(EventType type) { } @Override - public EventExecutionResult process(EventEntity eventEntity) { + public EventExecutionResult process(Event event) { try { - var notificationPayload = objectMapper.writeValueAsString(eventEntity.getParams()); - var event = objectMapper.readValue(notificationPayload, NotificationCreatedEvent.class); + var notificationPayload = objectMapper.writeValueAsString(event.params()); + var payload = objectMapper.readValue(notificationPayload, NotificationCreatedEvent.class); - log.info("Notification payload: {}", event); + log.info("Notification payload: {}", payload); for (var notifier : notifiers) { - for (NotificationChannel channel : event.notification().channels()) { + for (NotificationChannel channel : payload.notification().channels()) { if (notifier.accepts(channel)) { - notifier.notify(event.notification()); + notifier.notify(payload.notification()); } } } diff --git a/src/main/java/app/teamwize/api/notification/listener/NotificationEventHandler.java b/src/main/java/app/teamwize/api/notification/listener/NotificationEventHandler.java new file mode 100644 index 0000000..b0293ef --- /dev/null +++ b/src/main/java/app/teamwize/api/notification/listener/NotificationEventHandler.java @@ -0,0 +1,249 @@ +package app.teamwize.api.notification.listener; + +import app.teamwize.api.auth.domain.event.OrganizationCreatedEvent; +import app.teamwize.api.event.model.Event; +import app.teamwize.api.event.model.EventExitCode; +import app.teamwize.api.event.model.EventPayload; +import app.teamwize.api.event.model.EventType; +import app.teamwize.api.event.service.handler.EventHandler; +import app.teamwize.api.leave.model.event.LeaveCreatedEvent; +import app.teamwize.api.leave.model.event.LeaveStatusUpdatedEvent; +import app.teamwize.api.notification.model.Notification; +import app.teamwize.api.notification.model.NotificationTrigger; +import app.teamwize.api.notification.model.command.NotificationCreateCommand; +import app.teamwize.api.notification.model.event.NotificationCreatedEvent; +import app.teamwize.api.notification.service.NotificationService; +import app.teamwize.api.notification.service.NotificationTriggerService; +import app.teamwize.api.user.domain.UserRole; +import app.teamwize.api.user.domain.entity.User; +import app.teamwize.api.user.domain.event.UserInvitedEvent; +import app.teamwize.api.user.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Handles events to create user notifications based on configured triggers. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class NotificationEventHandler implements EventHandler { + + private final NotificationTriggerService notificationTriggerService; + private final NotificationService notificationService; + private final UserService userService; + private final ObjectMapper objectMapper; + + @Override + public String name() { + return this.getClass().getSimpleName(); + } + + @Override + public boolean accepts(EventType type) { + return type != EventType.NOTIFICATION_CREATED; + } + + @Override + public EventExecutionResult process(Event event) { + try { + long organizationId = event.organization().getId(); + List triggers = notificationTriggerService.getNotificationTriggers( + organizationId, + event.type() + ); + + if (triggers.isEmpty()) { + log.info("No triggers found for event type: {}", event.type()); + return new EventExecutionResult(EventExitCode.SUCCESS, Map.of("triggers", 0, "notifications", 0)); + } + + int notificationCount = createNotificationsForTriggers(event, triggers); + return new EventExecutionResult( + EventExitCode.SUCCESS, + Map.of("triggers", triggers.size(), "notifications", notificationCount) + ); + } catch (Exception e) { + log.error("Error while processing notification for event {}: {}", event.id(), e.getMessage(), e); + return new EventExecutionResult(EventExitCode.ERROR, Map.of("error", e.getMessage())); + } + } + + /** + * Creates notifications for all applicable triggers. + * + * @param event The event that triggered notifications + * @param triggers The notification triggers to process + * @return Count of notifications created + */ + private int createNotificationsForTriggers(Event event, List triggers) { + List notifications = new ArrayList<>(); + + for (NotificationTrigger trigger : triggers) { + try { + List receptors = getReceptorsForTrigger(event, trigger); + if (receptors.isEmpty()) { + log.info("No receptors found for event: {} and trigger: {}", event.type(), trigger.id()); + continue; + } + + log.debug("Processing notification for {} receptors, event: {}, trigger: {}", + receptors.size(), event.type(), trigger.id()); + + createNotificationsForReceptors(event, trigger, receptors, notifications); + } catch (Exception e) { + log.error("Failed to process trigger {}: {}", trigger.id(), e.getMessage(), e); + // Continue processing other triggers instead of failing the entire process + } + } + + return notifications.size(); + } + + /** + * Creates notifications for each receptor in the list. + * + * @param eventEntity The event that triggered notifications + * @param trigger The notification trigger configuration + * @param receptors List of users who should receive notifications + * @param notifications Collection to which created notifications are added + */ + private void createNotificationsForReceptors( + Event eventEntity, + NotificationTrigger trigger, + List receptors, + List notifications) { + + long organizationId = eventEntity.organization().getId(); + + for (User receptor : receptors) { + try { + NotificationCreateCommand command = new NotificationCreateCommand( + trigger.title(), + trigger.id(), + eventEntity.id(), + eventEntity.type(), + receptor, + eventEntity.params(), + trigger.channels().getFirst() + ); + + Notification notification = notificationService.createNotification(organizationId, command); + notifications.add(notification); + log.debug("Notification created for user {}, trigger {}", receptor.getId(), trigger.id()); + } catch (Exception e) { + log.error("Failed to create notification for user {}: {}", receptor.getId(), e.getMessage()); + // Continue with other receptors + } + } + } + + /** + * Gets the users who should receive the notification for a specific trigger. + * + * @param event The event that triggered the notification + * @param trigger The notification trigger configuration + * @return List of users who should receive the notification + */ + private List getReceptorsForTrigger(Event event, NotificationTrigger trigger) { + if (trigger.receptors() == null) { + return Collections.emptyList(); + } + try { + // Extract context information from the event + Long userId = null; + Long teamId = null; + + switch (event.type()) { + case USER_CREATED -> { + UserInvitedEvent payload = getEventPayload(event, UserInvitedEvent.class); + userId = payload.user().id(); + teamId = payload.user().teamId(); + } + case LEAVE_CREATED -> { + LeaveCreatedEvent payload = getEventPayload(event, LeaveCreatedEvent.class); + teamId = payload.user().teamId(); + userId = payload.user().id(); + } + case LEAVE_STATUS_UPDATED -> { + LeaveStatusUpdatedEvent payload = getEventPayload(event, LeaveStatusUpdatedEvent.class); + teamId = payload.user().teamId(); + userId = payload.user().id(); + } + case NOTIFICATION_CREATED -> { + NotificationCreatedEvent payload = getEventPayload(event, NotificationCreatedEvent.class); + userId = payload.notification().user().id(); + teamId = payload.notification().user().teamId(); + } + case ORGANIZATION_CREATED -> { + OrganizationCreatedEvent payload = getEventPayload(event, OrganizationCreatedEvent.class); + userId = payload.user().id(); + teamId = payload.user().teamId(); + } + default -> throw new IllegalStateException("Unsupported event type: " + event.type()); + } + + // Determine recipients based on the receptor type + long organizationId = event.organization().getId(); + return switch (trigger.receptors()) { + case USER -> { + if (userId == null) { + log.warn("User ID not found in event payload for event type: {}", event.type()); + yield Collections.emptyList(); + } + yield List.of(userService.getUser(organizationId, userId)); + } + case ALL_TEAM_MEMBERS -> { + if (teamId == null) { + log.warn("Team ID not found in event payload for event type: {}", event.type()); + yield Collections.emptyList(); + } + yield userService.getUsersByTeam( + organizationId, + teamId, + List.of(UserRole.EMPLOYEE, UserRole.TEAM_ADMIN) + ); + } + case REVIEWERS -> { + if (teamId == null) { + log.warn("Team ID not found in event payload for event type: {}", event.type()); + yield Collections.emptyList(); + } + var reviewers = new ArrayList(); + var orgAdmins = userService.getUsersByRole(organizationId, UserRole.ORGANIZATION_ADMIN); + var teamAdmins = userService.getUsersByTeam(organizationId, teamId, List.of(UserRole.TEAM_ADMIN)); + reviewers.addAll(orgAdmins); + reviewers.addAll(teamAdmins); + yield reviewers; + } + case ORGANIZATION_ADMIN -> userService.getUsersByRole(organizationId, UserRole.ORGANIZATION_ADMIN); + case TEAM_ADMIN -> userService.getUsersByTeam(organizationId, teamId, List.of(UserRole.TEAM_ADMIN)); + }; + } catch (Exception e) { + log.error("Error resolving receptors for event {} and trigger {}: {}", + event.id(), trigger.id(), e.getMessage(), e); + return Collections.emptyList(); + } + } + + /** + * Extracts an event payload from the event entity and deserializes it to the specified class. + * + * @param event The event entity + * @param clazz Target class for deserialization + * @return Deserialized payload + * @throws IOException If deserialization fails + */ + private T getEventPayload(Event event, Class clazz) throws IOException { + String payloadJson = objectMapper.writeValueAsString(event.params()); + return objectMapper.readValue(payloadJson, clazz); + } +} \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java index 963f33d..c5f51a3 100644 --- a/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java +++ b/src/main/java/app/teamwize/api/notification/model/NotificationTriggerReceptors.java @@ -6,4 +6,5 @@ public enum NotificationTriggerReceptors { TEAM_ADMIN, ORGANIZATION_ADMIN, ALL_TEAM_MEMBERS, + REVIEWERS, } \ No newline at end of file diff --git a/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java index 054308a..91560a1 100644 --- a/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java +++ b/src/main/java/app/teamwize/api/notification/model/command/NotificationTriggerCreateCommand.java @@ -2,6 +2,7 @@ import app.teamwize.api.event.model.EventType; import app.teamwize.api.notification.model.NotificationChannel; +import app.teamwize.api.notification.model.NotificationTriggerReceptors; import java.util.List; @@ -12,5 +13,5 @@ public record NotificationTriggerCreateCommand( String htmlTemplate, EventType eventType, List channels, - String receptors) { + NotificationTriggerReceptors receptors) { } diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java b/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java deleted file mode 100644 index 33de211..0000000 --- a/src/main/java/app/teamwize/api/notification/service/NotificationEventHandler.java +++ /dev/null @@ -1,149 +0,0 @@ -package app.teamwize.api.notification.service; - -import app.teamwize.api.auth.domain.event.OrganizationCreatedEvent; -import app.teamwize.api.event.entity.EventEntity; -import app.teamwize.api.event.model.EventExitCode; -import app.teamwize.api.event.model.EventPayload; -import app.teamwize.api.event.model.EventType; -import app.teamwize.api.event.service.handler.EventHandler; -import app.teamwize.api.leave.model.event.LeaveCreatedEvent; -import app.teamwize.api.leave.model.event.LeaveStatusUpdatedEvent; -import app.teamwize.api.notification.model.Notification; -import app.teamwize.api.notification.model.NotificationTrigger; -import app.teamwize.api.notification.model.command.NotificationCreateCommand; -import app.teamwize.api.notification.model.event.NotificationCreatedEvent; -import app.teamwize.api.user.domain.UserRole; -import app.teamwize.api.user.domain.entity.User; -import app.teamwize.api.user.domain.event.UserInvitedEvent; -import app.teamwize.api.user.exception.UserNotFoundException; -import app.teamwize.api.user.service.UserService; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -@Component -@Slf4j -@RequiredArgsConstructor -public class NotificationEventHandler implements EventHandler { - - private final NotificationTriggerService notificationTriggerService; - private final NotificationService notificationService; - private final UserService userService; - private final ObjectMapper objectMapper; - - @Override - public String name() { - return this.getClass().getSimpleName(); - } - - @Override - public boolean accepts(EventType type) { - return type != EventType.NOTIFICATION_CREATED; - } - - @Override - public EventExecutionResult process(EventEntity eventEntity) { - var triggers = notificationTriggerService.getNotificationTriggers( - eventEntity.getOrganization().getId(), - eventEntity.getType() - ); - var notifications = new ArrayList(); - try { - for (var trigger : triggers) { - // Then compile with handlebars for any dynamic values - var receptors = compileReceptors(eventEntity, trigger); - log.info("Processing notification for receptors: {} , event : {} , trigger : {}", receptors, eventEntity.getType(), trigger.id()); - for (var receptor : receptors) { - // try to compile the template - var notification = notificationService.createNotification(eventEntity.getOrganization().getId(), - new NotificationCreateCommand( - trigger.title(), - trigger.id(), - eventEntity.getId(), - eventEntity.getType(), - receptor, - eventEntity.getParams(), - trigger.channels().getFirst()) - ); - notifications.add(notification); - log.info("Notification created: {}", notification); - } - } - return new EventExecutionResult(EventExitCode.SUCCESS, - Map.of("triggers", triggers.size(), "notifications", notifications.size()) - ); - } catch (Exception e) { - log.error("Error while processing notification", e); - return new EventExecutionResult(EventExitCode.ERROR, Map.of("error", e.getMessage())); - } - } - - private T getEventPayload(EventEntity event, Class clazz) throws IOException { - var notificationPayload = objectMapper.writeValueAsString(event.getParams()); - return objectMapper.readValue(notificationPayload, clazz); - } - - private List compileReceptors(EventEntity event, NotificationTrigger trigger) throws IOException, UserNotFoundException { - if (trigger.receptors() == null) { - return List.of(); - } - var organizationId = event.getOrganization().getId(); - Long teamId; - Long userId; - switch (event.getType()) { - case USER_CREATED -> { - var payload = getEventPayload(event, UserInvitedEvent.class); - userId = payload.user().id(); - teamId = payload.user().teamId(); - } - case LEAVE_CREATED -> { - var payload = getEventPayload(event, LeaveCreatedEvent.class); - teamId = payload.user().teamId(); - userId = payload.user().id(); - } - case LEAVE_STATUS_UPDATED -> { - var payload = getEventPayload(event, LeaveStatusUpdatedEvent.class); - - teamId = payload.user().teamId(); - userId = payload.user().id(); - } - case NOTIFICATION_CREATED -> { - var payload = getEventPayload(event, NotificationCreatedEvent.class); - userId = payload.notification().user().id(); - teamId = payload.notification().user().teamId(); - } - case ORGANIZATION_CREATED -> { - var payload = getEventPayload(event, OrganizationCreatedEvent.class); - userId = payload.user().id(); - teamId = payload.user().teamId(); - } - default -> { - throw new IllegalStateException("Unexpected value: " + event.getType()); - } - } - - switch (trigger.receptors()) { - case USER -> { - var user = userService.getUser(organizationId, userId); - return List.of(user); - } - case ALL_TEAM_MEMBERS -> { - return userService.getUsersByTeam(organizationId, teamId, List.of(UserRole.EMPLOYEE, UserRole.TEAM_ADMIN)); - } - case ORGANIZATION_ADMIN -> { - return userService.getUsersByRole(event.getOrganization().getId(), UserRole.ORGANIZATION_ADMIN); - } - case TEAM_ADMIN -> { - return userService.getUsersByRole(event.getOrganization().getId(), UserRole.TEAM_ADMIN); - } - } - return Collections.emptyList(); - } -} diff --git a/src/main/java/app/teamwize/api/notification/service/NotificationService.java b/src/main/java/app/teamwize/api/notification/service/NotificationService.java index 4ca9bfb..4fb81b7 100644 --- a/src/main/java/app/teamwize/api/notification/service/NotificationService.java +++ b/src/main/java/app/teamwize/api/notification/service/NotificationService.java @@ -3,6 +3,7 @@ import app.teamwize.api.base.domain.model.Paged; import app.teamwize.api.base.domain.model.request.PaginationRequest; import app.teamwize.api.event.service.EventService; +import app.teamwize.api.notification.config.NotificationConfigModel; import app.teamwize.api.notification.exception.NotificationTemplateCompileException; import app.teamwize.api.notification.exception.NotificationTriggerNotFoundException; import app.teamwize.api.notification.mapper.NotificationMapper; @@ -42,6 +43,7 @@ public class NotificationService { private final NotificationTriggerMapper notificationTriggerMapper; private final OrganizationService organizationService; private final UserService userService; + private final NotificationConfigModel config; @Lazy private final EventService eventService; private final Handlebars handlebars; @@ -56,6 +58,8 @@ public Notification createNotification(Long organizationId, NotificationCreateCo var trigger = triggerService.getNotificationTrigger(organizationId, command.triggerId()); var organization = organizationService.getOrganization(organizationId); + entity.getParams().put("baseUrl", config.email().baseUrl()); + try { var textContent = handlebars.compile(new StringTemplateSource("notification", trigger.textTemplate())).apply(entity.getParams()); var htmlContent = handlebars.compile(new StringTemplateSource("notification", trigger.htmlTemplate())).apply(entity.getParams()); diff --git a/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java b/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java index 3ab33f7..1069473 100644 --- a/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java +++ b/src/main/java/app/teamwize/api/notification/service/notifier/EmailNotifier.java @@ -1,5 +1,6 @@ package app.teamwize.api.notification.service.notifier; +import app.teamwize.api.notification.config.NotificationConfigModel; import app.teamwize.api.notification.exception.NotificationSendFailureException; import app.teamwize.api.notification.model.NotificationChannel; import app.teamwize.api.notification.model.event.NotificationEventPayload; @@ -7,15 +8,13 @@ import com.github.jknack.handlebars.io.StringTemplateSource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; import java.nio.charset.Charset; import java.time.LocalDate; -import java.util.Map; +import java.util.HashMap; @Slf4j @@ -25,8 +24,7 @@ public class EmailNotifier implements Notifier { private final JavaMailSender mailSender; private final Handlebars templateEngine; - @Value("classpath:templates/email/layout.hbs") - private Resource emailTemplate; + private final NotificationConfigModel config; @Override public boolean accepts(NotificationChannel channel) { @@ -41,15 +39,18 @@ public void notify(NotificationEventPayload event) throws NotificationSendFailur helper.setTo(event.user().email()); helper.setSubject(event.title()); - var context = Map.of( - "title", event.id(), - "body", event.htmlContent(), - "year", LocalDate.now().getYear(), - "unsubscribeUrl", "https://teamwize.app/unsubscribe?email=" + event.user().email(), - "companyName", "TeamPilot" - ); + var unsubscribeUrl = config.email().unsubscribeUrl() == null ? null : config.email().unsubscribeUrl() + "?email=" + event.user().email(); - var html = templateEngine.compile(new StringTemplateSource("email", emailTemplate.getContentAsString(Charset.defaultCharset()))).apply(context); + var context = new HashMap(); + context.put("title", event.title()); + context.put("body", event.htmlContent()); + context.put("year", LocalDate.now().getYear()); + context.put("unsubscribeUrl", unsubscribeUrl); + context.put("companyName", config.email().companyName()); + context.put("baseUrl", config.email().baseUrl()); + + var baseTemplateRawHTML = config.email().baseTemplate().getContentAsString(Charset.defaultCharset()); + var html = templateEngine.compile(new StringTemplateSource("email", baseTemplateRawHTML)).apply(context); helper.setText(html, true); // Set to true for HTML diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c218b2d..af267e9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,13 @@ server: port: 8001 app: + notification: + email: + from: + unsubscribe-url: + company-name: + base-template: + base-url: storage: temp-directory: ${java.io.tmpdir} base-preview-url: https://teamwize.app/assets/preview diff --git a/src/main/resources/templates/email/default-leave-created-reviewer.html b/src/main/resources/templates/email/default-leave-created-reviewer.html new file mode 100644 index 0000000..e34934d --- /dev/null +++ b/src/main/resources/templates/email/default-leave-created-reviewer.html @@ -0,0 +1,44 @@ +
+

New Leave Request Requires Approval

+
+ +
+

A new leave request has been submitted that requires your review and approval.

+ +
+

Employee Information

+

+ Name: {{user.firstName}} {{user.lastName}}
+ Email: {{user.email}}
+ Phone: {{user.phone}}
+ Team ID: {{user.teamId}}
+ Country: {{user.country}}
+ Timezone: {{user.timezone}} +

+
+ +
+

Leave Request Details

+

+ Leave ID: {{leave.id}}
+ Type: {{leave.type.name}}
+ Policy: {{leave.type.policyName}} (ID: {{leave.type.policyId}})
+ Duration: {{leave.duration}} day(s)
+ Start Date: {{leave.startAt}}
+ End Date: {{leave.endAt}}
+ Status: {{leave.status}}
+ {{#if leave.reason}} + Reason: {{leave.reason}} + {{/if}} +

+
+ + + +

Please review this request in the leave management portal where you can approve or reject it based on your + organization's policies.

+ +

Thank you,
Human Resources Team

+
\ No newline at end of file diff --git a/src/main/resources/templates/email/default-leave-created-reviewer.txt b/src/main/resources/templates/email/default-leave-created-reviewer.txt new file mode 100644 index 0000000..6f7a243 --- /dev/null +++ b/src/main/resources/templates/email/default-leave-created-reviewer.txt @@ -0,0 +1,33 @@ +NEW LEAVE REQUEST REQUIRES APPROVAL + +A new leave request has been submitted that requires your review and approval. + +EMPLOYEE INFORMATION +------------------- +Name: {{user.firstName}} {{user.lastName}} +Email: {{user.email}} +Phone: {{user.phone}} +Team ID: {{user.teamId}} +Country: {{user.country}} +Timezone: {{user.timezone}} + +LEAVE REQUEST DETAILS +------------------- +Leave ID: {{leave.id}} +Type: {{leave.type.name}} +Policy: {{leave.type.policyName}} (ID: {{leave.type.policyId}}) +Duration: {{leave.duration}} day(s) +Start Date: {{leave.startAt}} +End Date: {{leave.endAt}} +Status: {{leave.status}} +{{#if leave.reason}} +Reason: {{leave.reason}} +{{/if}} + +To review this leave request, please visit: +{{baseUrl}}/leaves/ + +Please review this request in the leave management portal where you can approve or reject it based on your organization's policies. + +Thank you, +Human Resources Team \ No newline at end of file diff --git a/src/main/resources/templates/email/default-leave-created-user.html b/src/main/resources/templates/email/default-leave-created-user.html new file mode 100644 index 0000000..5e1e90c --- /dev/null +++ b/src/main/resources/templates/email/default-leave-created-user.html @@ -0,0 +1,56 @@ +
+

Leave Request Created

+
+
+

Hello {{user.firstName}} {{user.lastName}},

+

Your leave request has been successfully created with the following details:

+
+
+ Leave Type: + {{leave.type.name}} +
+ +
+ Start Date: + {{leave.startAt}} +
+ +
+ End Date: + {{leave.endAt}} +
+ +
+ Duration: + {{leave.duration}} day(s) +
+ +
+ Reason: + {{leave.reason}} +
+ +
+ Status: + {{leave.status}} +
+ +
+ Request ID: + #{{leave.id}} +
+
+ + {{#if leave.type.requiresApproval}} +

Your request requires approval. You will be notified once a decision has been made.

+ {{else}} +

Your request has been automatically approved according to the {{leave.type.policyName}} policy.

+ {{/if}} + +

If you have any questions about your leave request, please contact your team administrator.

+ +

Thank you,
Human Resources Team

+
+ \ No newline at end of file diff --git a/src/main/resources/templates/email/default-leave-created-user.txt b/src/main/resources/templates/email/default-leave-created-user.txt new file mode 100644 index 0000000..069c554 --- /dev/null +++ b/src/main/resources/templates/email/default-leave-created-user.txt @@ -0,0 +1,19 @@ +Your leave request has been successfully created with the following details: + +Leave Type: {{leave.type.name}} +Start Date: {{leave.startAt}} +End Date: {{leave.endAt}} +Duration: {{leave.duration}} day(s) +Reason: {{leave.reason}} +Status: {{leave.status}} + +{{#if leave.type.requiresApproval}} +Your request requires approval. You will be notified once a decision has been made. +{{else}} +Your request has been automatically approved according to the {{leave.type.policyName}} policy. +{{/if}} + +If you have any questions about your leave request, please contact your team administrator. + +Thank you, +Human Resources Team \ No newline at end of file diff --git a/src/main/resources/templates/email/default-leave-updated-user.html b/src/main/resources/templates/email/default-leave-updated-user.html new file mode 100644 index 0000000..e8ba1f2 --- /dev/null +++ b/src/main/resources/templates/email/default-leave-updated-user.html @@ -0,0 +1,43 @@ +
+

Update on Your Leave Request

+
+ +
+

Hello {{user.firstName}},

+ +

Your leave request status has been updated.

+ +
+

Leave Request Details

+

+ Leave ID: {{leave.id}}
+ Type: {{leave.type.name}}
+ Policy: {{leave.type.policyName}}
+ Duration: {{leave.duration}} day(s)
+ Start Date: {{formatDate leave.startAt}}
+ End Date: {{formatDate leave.endAt}}
+ Status: {{leave.status}}
+

+
+ + {{#if leave.status == "ACCEPTED"}} +
+

Your leave request has been approved. We've updated your calendar accordingly.

+
+ {{/if}} + + {{#if leave.status == "REJECTED"}} +
+

Unfortunately, your leave request has been declined. Please contact your manager if you have + any questions.

+
+ {{/if}} + + + +

If you have any questions regarding this update, please contact your manager or HR department.

+ +

Thank you,
Human Resources Team

+
\ No newline at end of file diff --git a/src/main/resources/templates/email/default-leave-updated-user.txt b/src/main/resources/templates/email/default-leave-updated-user.txt new file mode 100644 index 0000000..ef63e4f --- /dev/null +++ b/src/main/resources/templates/email/default-leave-updated-user.txt @@ -0,0 +1,35 @@ +UPDATE ON YOUR LEAVE REQUEST + +Hello {{user.firstName}}, + +Your leave request status has been updated. + +LEAVE REQUEST DETAILS +------------------- +Leave ID: {{leave.id}} +Type: {{leave.type.name}} +Policy: {{leave.type.policyName}} +Duration: {{leave.duration}} day(s) +Start Date: {{formatDate leave.startAt}} +End Date: {{formatDate leave.endAt}} +Status: {{leave.status}} +{{#if leave.reason}} +Reason Provided: {{leave.reason}} +{{/if}} + + +{{#if leave.status == "ACCEPTED"}} +Your leave request has been APPROVED. We've updated your calendar accordingly. +{{/if}} + +{{#if leave.status == "REJECTED"}} +Unfortunately, your leave request has been DECLINED. Please contact your manager if you have any questions. +{{/if}} + +To view the complete details of your leave request, please visit: +{{baseUrl}}/leaves/{{leave.id}} + +If you have any questions regarding this update, please contact your manager or HR department. + +Thank you, +Human Resources Team \ No newline at end of file diff --git a/src/main/resources/templates/email/layout.hbs b/src/main/resources/templates/email/layout.hbs deleted file mode 100644 index c40a86a..0000000 --- a/src/main/resources/templates/email/layout.hbs +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - {{title}} - - - - - - -
- - - - - - - - - - - - - - - -
- Logo -
- - - - -
- {{{body}}} -
-
-

© {{year}} {{companyName}}. All rights reserved.

- {{#if unsubscribeUrl}} -

- Unsubscribe -

- {{/if}} -
-
- - \ No newline at end of file diff --git a/src/main/resources/templates/email/layout.html b/src/main/resources/templates/email/layout.html new file mode 100644 index 0000000..fd52669 --- /dev/null +++ b/src/main/resources/templates/email/layout.html @@ -0,0 +1,83 @@ + + + + + + {{title}} + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + +
+ {{{body}}} +
+
+

© {{year}} {{companyName}}. All rights reserved.

+ {{#if unsubscribeUrl}} +

+ Unsubscribe +

+ {{/if}} +
+
+ + \ No newline at end of file From cbc756a930429520122de5f3ae7f9838885c3cbc Mon Sep 17 00:00:00 2001 From: Mohsen Karimi Date: Sun, 9 Mar 2025 18:13:38 +0100 Subject: [PATCH 3/3] feat: implement default notification triggers for leave requests and updates Signed-off-by: Mohsen Karimi --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index af267e9..c89946e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,7 @@ app: from: unsubscribe-url: company-name: - base-template: + base-template: "classpath:templates/email/layout.html" base-url: storage: temp-directory: ${java.io.tmpdir}