diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/CommentController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/CommentController.java index 7d37d09b08..9c57753dae 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/CommentController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/CommentController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -41,16 +42,16 @@ public class CommentController extends BaseController new ResponseDTO<>(HttpStatus.CREATED.value(), created, null)); } @PostMapping("/threads") @ResponseStatus(HttpStatus.CREATED) public Mono> createThread(@Valid @RequestBody CommentThread resource, - ServerWebExchange exchange) { + @RequestHeader(name = "Origin") String originHeader) { log.debug("Going to create resource {}", resource.getClass().getName()); - return service.createThread(resource) + return service.createThread(resource, originHeader) .map(created -> new ResponseDTO<>(HttpStatus.CREATED.value(), created, null)); } @@ -63,10 +64,10 @@ public class CommentController extends BaseController> updateThread( @Valid @RequestBody CommentThread resource, - @PathVariable String threadId + @PathVariable String threadId, ServerWebExchange exchange ) { log.debug("Going to update resource {}", resource.getClass().getName()); - return service.updateThread(threadId, resource) + return service.updateThread(threadId, resource, exchange.getRequest().getHeaders().getOrigin()) .map(updated -> new ResponseDTO<>(HttpStatus.ACCEPTED.value(), updated, null)); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/events/AbstractCommentEvent.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/AbstractCommentEvent.java new file mode 100644 index 0000000000..003947218f --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/AbstractCommentEvent.java @@ -0,0 +1,13 @@ +package com.appsmith.server.events; + +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.Organization; +import lombok.Data; + +@Data +public abstract class AbstractCommentEvent { + private final String authorUserName; + private final Organization organization; + private final Application application; + private final String originHeader; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentAddedEvent.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentAddedEvent.java new file mode 100644 index 0000000000..441702d790 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentAddedEvent.java @@ -0,0 +1,16 @@ +package com.appsmith.server.events; + +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.Comment; +import com.appsmith.server.domains.Organization; +import lombok.Getter; + +@Getter +public class CommentAddedEvent extends AbstractCommentEvent { + private final Comment comment; + + public CommentAddedEvent(String authorUserName, Organization organization, Application application, String originHeader, Comment comment) { + super(authorUserName, organization, application, originHeader); + this.comment = comment; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentThreadClosedEvent.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentThreadClosedEvent.java new file mode 100644 index 0000000000..6c2fc7bea1 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/CommentThreadClosedEvent.java @@ -0,0 +1,16 @@ +package com.appsmith.server.events; + +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.CommentThread; +import com.appsmith.server.domains.Organization; +import lombok.Getter; + +@Getter +public class CommentThreadClosedEvent extends AbstractCommentEvent { + private final CommentThread commentThread; + + public CommentThreadClosedEvent(String authorUserName, Organization organization, Application application, String originHeader, CommentThread commentThread) { + super(authorUserName, organization, application, originHeader); + this.commentThread = commentThread; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/events/UserChangedEvent.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/UserChangedEvent.java index ea6c2a0bec..af2d27d937 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/events/UserChangedEvent.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/events/UserChangedEvent.java @@ -2,10 +2,8 @@ package com.appsmith.server.events; import com.appsmith.server.domains.User; import lombok.Data; -import lombok.RequiredArgsConstructor; @Data -@RequiredArgsConstructor public class UserChangedEvent { private final User user; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommentUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommentUtils.java new file mode 100644 index 0000000000..50230fb998 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CommentUtils.java @@ -0,0 +1,43 @@ +package com.appsmith.server.helpers; + +import com.appsmith.server.domains.Comment; + +import java.util.ArrayList; +import java.util.List; + +public class CommentUtils { + /** + * Checks whether provided user has been mentioned in the comment. Returns true if yes and false otherwise. + * @param comment Comment objects + * @param userEmail email address of the user + * @return true or false based on the condition + */ + public static boolean isUserMentioned(Comment comment, String userEmail) { + if(comment.getBody() != null && comment.getBody().getEntityMap() != null) { + for(String key : comment.getBody().getEntityMap().keySet()) { + Comment.Entity commentEntity = comment.getBody().getEntityMap().get(key); + if(commentEntity != null && commentEntity.getType() != null + && commentEntity.getType().equals("mention")) { + // this comment has a mention, check the provided user is mentioned or not + if(commentEntity.getData() != null) { + Comment.EntityData.Mention mention = commentEntity.getData().getMention(); + if(mention.getUser().getUsername().equals(userEmail)) { + return true; + } + } + } + } + } + return false; + } + + public static List getCommentBody(Comment comment) { + List commentLines = new ArrayList<>(); + if(comment.getBody() != null && comment.getBody().getBlocks() != null) { + for (Comment.Block block : comment.getBody().getBlocks()) { + commentLines.add(block.getText()); + } + } + return commentLines; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/notifications/EmailSender.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/notifications/EmailSender.java index 00e320d92e..c90ad40cf8 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/notifications/EmailSender.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/notifications/EmailSender.java @@ -43,7 +43,7 @@ public class EmailSender { REPLY_TO = makeReplyTo(); } - public Mono sendMail(String to, String subject, String text, Map params) { + public Mono sendMail(String to, String subject, String text, Map params) { /** * Creating a publisher which sends email in a blocking fashion, subscribing on the bounded elastic @@ -122,7 +122,7 @@ public class EmailSender { * @return Template string with Mustache replacements applied. * @throws IOException bubbled from Mustache renderer. */ - private String replaceEmailTemplate(String template, Map params) throws IOException { + private String replaceEmailTemplate(String template, Map params) throws IOException { MustacheFactory mf = new DefaultMustacheFactory(); StringWriter stringWriter = new StringWriter(); Mustache mustache = mf.compile(template); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentService.java index 4960633140..f20289c7ea 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentService.java @@ -8,11 +8,11 @@ import java.util.List; public interface CommentService extends CrudService { - Mono create(String threadId, Comment organization); + Mono create(String threadId, Comment organization, String originHeader); - Mono createThread(CommentThread commentThread); + Mono createThread(CommentThread commentThread, String originHeader); - Mono updateThread(String threadId, CommentThread commentThread); + Mono updateThread(String threadId, CommentThread commentThread, String originHeader); Mono> getThreadsByApplicationId(String applicationId); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java index 95de56d12c..7643c784bb 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/CommentServiceImpl.java @@ -11,11 +11,14 @@ import com.appsmith.server.domains.CommentThread; import com.appsmith.server.domains.CommentThreadNotification; import com.appsmith.server.domains.Notification; import com.appsmith.server.domains.User; +import com.appsmith.server.events.CommentAddedEvent; +import com.appsmith.server.events.CommentThreadClosedEvent; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.helpers.PolicyUtils; import com.appsmith.server.repositories.CommentRepository; import com.appsmith.server.repositories.CommentThreadRepository; +import com.appsmith.server.solutions.EmailEventHandler; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -52,6 +55,7 @@ public class CommentServiceImpl extends BaseService create(String threadId, Comment comment) { - return create(threadId, comment, true); + public Mono create(String threadId, Comment comment, String originHeader) { + return create(threadId, comment, originHeader, true); } - public Mono create(String threadId, Comment comment, boolean shouldCreateNotification) { + private Mono create(String threadId, Comment comment, String originHeader, boolean shouldCreateNotification) { if (StringUtils.isWhitespace(comment.getAuthorName())) { // Error: User can't explicitly set the author name. It will be the currently logged in user. return Mono.empty(); @@ -125,36 +130,40 @@ public class CommentServiceImpl extends BaseService { final User user = tuple.getT1(); - final Comment savedComment = tuple.getT2(); + CommentThread commentThread = tuple.getT2(); + final Comment savedComment = tuple.getT3(); + Mono publishEmailMono = emailEventHandler.publish( + comment.getAuthorUsername(), commentThread.getApplicationId(), comment, originHeader + ); if (shouldCreateNotification) { final Set usernames = policyUtils.findUsernamesWithPermission( savedComment.getPolicies(), AclPermission.READ_COMMENT); - - List> monos = new ArrayList<>(); + List> notificationMonos = new ArrayList<>(); for (String username : usernames) { if (!username.equals(user.getUsername())) { final CommentNotification notification = new CommentNotification(); notification.setComment(savedComment); notification.setForUsername(username); - monos.add(notificationService.create(notification)); + Mono notificationMono = notificationService.create(notification); + notificationMonos.add(notificationMono); } } - - return Flux.concat(monos).then(Mono.just(savedComment)); + return Flux.concat(notificationMonos).then(publishEmailMono).thenReturn(savedComment); } else { - return Mono.just(savedComment); + return publishEmailMono.thenReturn(savedComment); } }); } @Override - public Mono createThread(CommentThread commentThread) { + public Mono createThread(CommentThread commentThread, String originHeader) { // 1. Check if this user has permission on the application given by `commentThread.applicationId`. // 2. Save the comment thread and get it's id. This is the `threadId`. // 3. Pull the comment out of the list of comments, set it's `threadId` and save it separately. @@ -211,7 +220,7 @@ public class CommentServiceImpl extends BaseService updateThread(String threadId, CommentThread commentThread) { + public Mono updateThread(String threadId, CommentThread commentThread, String originHeader) { return Mono.zip( sessionUserService.getCurrentUser(), // Resolving, pinning and marking as read don't need manage permission on the thread. @@ -301,7 +309,14 @@ public class CommentServiceImpl extends BaseService { updatedThread.setIsViewed(true); - return Mono.just(updatedThread); + // send email if comment thread is resolved + CommentThread.CommentThreadState resolvedState = commentThread.getResolvedState(); + if(resolvedState != null && resolvedState.getActive()) { + return emailEventHandler.publish(user.getUsername(), updatedThread.getApplicationId(), + updatedThread, originHeader).thenReturn(updatedThread); + } else { + return Mono.just(updatedThread); + } }); }); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java index cce58ac380..8c4d9cb739 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java @@ -1,6 +1,12 @@ package com.appsmith.server.services; +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.Comment; import com.appsmith.server.domains.Notification; +import com.appsmith.server.domains.Organization; +import reactor.core.publisher.Mono; + +import java.util.List; public interface NotificationService extends CrudService { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java index 614e68fddc..0b88af94c5 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java @@ -29,8 +29,7 @@ public class NotificationServiceImpl ReactiveMongoTemplate reactiveMongoTemplate, NotificationRepository repository, AnalyticsService analyticsService, - SessionUserService sessionUserService - ) { + SessionUserService sessionUserService) { super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService); this.sessionUserService = sessionUserService; } @@ -57,5 +56,4 @@ public class NotificationServiceImpl return sessionUserService.getCurrentUser() .flatMapMany(user -> repository.findByForUsername(user.getUsername())); } - } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java index d8659fe97b..efd0f10899 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java @@ -48,7 +48,8 @@ import static com.appsmith.server.acl.AclPermission.USER_MANAGE_ORGANIZATIONS; @Slf4j @Service -public class OrganizationServiceImpl extends BaseService implements OrganizationService { +public class OrganizationServiceImpl extends BaseService + implements OrganizationService { private final PluginRepository pluginRepository; private final SessionUserService sessionUserService; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/EmailEventHandler.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/EmailEventHandler.java new file mode 100644 index 0000000000..a3be8b56a8 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/EmailEventHandler.java @@ -0,0 +1,152 @@ +package com.appsmith.server.solutions; + +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.Comment; +import com.appsmith.server.domains.CommentThread; +import com.appsmith.server.domains.Organization; +import com.appsmith.server.domains.UserRole; +import com.appsmith.server.events.CommentAddedEvent; +import com.appsmith.server.events.CommentThreadClosedEvent; +import com.appsmith.server.helpers.CommentUtils; +import com.appsmith.server.notifications.EmailSender; +import com.appsmith.server.repositories.ApplicationRepository; +import com.appsmith.server.repositories.OrganizationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.ArrayList; +import java.util.HashMap; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class EmailEventHandler { + private static final String COMMENT_ADDED_EMAIL_TEMPLATE = "email/commentAddedTemplate.html"; + private static final String USER_MENTIONED_EMAIL_TEMPLATE = "email/userTaggedInCommentTemplate.html"; + private static final String THREAD_RESOLVED_EMAIL_TEMPLATE = "email/commentResolvedTemplate.html"; + + private final ApplicationEventPublisher applicationEventPublisher; + private final EmailSender emailSender; + private final OrganizationRepository organizationRepository; + private final ApplicationRepository applicationRepository; + + public Mono publish(String authorUserName, String applicationId, Comment comment, String originHeader) { + return applicationRepository.findById(applicationId).flatMap(application -> { + return organizationRepository.findById(application.getOrganizationId()).flatMap(organization -> { + applicationEventPublisher.publishEvent( + new CommentAddedEvent(authorUserName, organization, application, originHeader, comment) + ); + return Mono.just(organization); + }); + }).thenReturn(Boolean.TRUE); + } + + public Mono publish(String authorUserName, String applicationId, CommentThread thread, String originHeader) { + return applicationRepository.findById(applicationId).flatMap(application -> { + return organizationRepository.findById(application.getOrganizationId()).flatMap(organization -> { + applicationEventPublisher.publishEvent( + new CommentThreadClosedEvent(authorUserName, organization, application, originHeader, thread) + ); + return Mono.just(organization); + }); + }).thenReturn(Boolean.TRUE); + } + + @Async + @EventListener + public void handle(CommentAddedEvent event) { + this.sendEmailForComment( + event.getAuthorUserName(), event.getApplication(), event.getOrganization(), + event.getComment(), event.getOriginHeader() + ).subscribeOn(Schedulers.elastic()) + .subscribe(); + } + + @Async + @EventListener + public void handle(CommentThreadClosedEvent event) { + this.sendEmailForComment( + event.getAuthorUserName(), event.getApplication(), event.getOrganization(), + event.getCommentThread(), event.getOriginHeader() + ) + .subscribeOn(Schedulers.elastic()) + .subscribe(); + } + + private Mono getEmailSenderMono(UserRole receiverUserRole, CommentThread commentThread, + String originHeader, Application application, Organization organization) { + String receiverName = StringUtils.isEmpty(receiverUserRole.getName()) ? "User" : receiverUserRole.getName(); + String receiverEmail = receiverUserRole.getUsername(); + CommentThread.CommentThreadState resolvedState = commentThread.getResolvedState(); + Map templateParams = new HashMap<>(); + templateParams.put("App_User_Name", receiverName); + templateParams.put("Commenter_Name", resolvedState.getAuthorName()); + templateParams.put("Application_Name", application.getName()); + templateParams.put("Organization_Name", organization.getName()); + templateParams.put("inviteUrl", originHeader); + + String emailSubject = String.format( + "%s has resolved comment in %s", resolvedState.getAuthorName(), application.getName() + ); + return emailSender.sendMail(receiverEmail, emailSubject, THREAD_RESOLVED_EMAIL_TEMPLATE, templateParams); + } + + private Mono getEmailSenderMono(UserRole receiverUserRole, Comment comment, String originHeader, + Application application, Organization organization) { + String receiverName = StringUtils.isEmpty(receiverUserRole.getName()) ? "User" : receiverUserRole.getName(); + String receiverEmail = receiverUserRole.getUsername(); + + Map templateParams = new HashMap<>(); + templateParams.put("App_User_Name", receiverName); + templateParams.put("Commenter_Name", comment.getAuthorName()); + templateParams.put("Application_Name", application.getName()); + templateParams.put("Organization_Name", organization.getName()); + templateParams.put("Comment_Body", CommentUtils.getCommentBody(comment)); + templateParams.put("inviteUrl", originHeader); + + String emailTemplate = COMMENT_ADDED_EMAIL_TEMPLATE; + String emailSubject = String.format( + "New comment from %s in %s", comment.getAuthorName(), application.getName() + ); + + // check if user has been mentioned in the comment + if(CommentUtils.isUserMentioned(comment, receiverEmail)) { + emailTemplate = USER_MENTIONED_EMAIL_TEMPLATE; + emailSubject = String.format("New comment for you from %s", comment.getAuthorName()); + } + return emailSender.sendMail(receiverEmail, emailSubject, emailTemplate, templateParams); + } + + private Mono sendEmailForComment(String authorUserName, Application application, Organization organization, + E commentDomain, String originHeader) { + + List> emailMonos = new ArrayList<>(); + for (UserRole userRole : organization.getUserRoles()) { + if(!authorUserName.equals(userRole.getUsername())) { + if(commentDomain instanceof Comment) { + Comment comment = (Comment)commentDomain; + emailMonos.add( + getEmailSenderMono(userRole, comment, originHeader, application, organization) + ); + } else if(commentDomain instanceof CommentThread) { + CommentThread commentThread = (CommentThread) commentDomain; + emailMonos.add( + getEmailSenderMono(userRole, commentThread, originHeader, application, organization) + ); + } + } + } + return Flux.concat(emailMonos).then(Mono.just(Boolean.TRUE)); + } +} diff --git a/app/server/appsmith-server/src/main/resources/email/commentAddedTemplate.html b/app/server/appsmith-server/src/main/resources/email/commentAddedTemplate.html new file mode 100644 index 0000000000..55cd64b88d --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/email/commentAddedTemplate.html @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + +
+ +
+ + + + + + +
+
+
+
+
+ Hi {{App_User_Name}}, +
+
+ {{Commenter_Name}} just added a new comment in {{Application_Name}} in the {{Organization_Name}} organization. +
+
+ {{#Comment_Body}} +
{{ . }}
+ {{/Comment_Body}} +
+
+ Please follow this link to view and respond to the comment. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Go to Comment +
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/email/commentResolvedTemplate.html b/app/server/appsmith-server/src/main/resources/email/commentResolvedTemplate.html new file mode 100644 index 0000000000..5c28f11d85 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/email/commentResolvedTemplate.html @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + +
+ +
+ + + + + + +
+
+
+
+
+ Hi {{App_User_Name}}, +
+
+ {{Commenter_Name}} has resolved the comment in {{Application_Name}} in the {{Organization_Name}} organization. +
+ +
+ Please follow this link to view and re-open the comment. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Go to Comment +
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/email/userTaggedInCommentTemplate.html b/app/server/appsmith-server/src/main/resources/email/userTaggedInCommentTemplate.html new file mode 100644 index 0000000000..541e1348f7 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/email/userTaggedInCommentTemplate.html @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + +
+ +
+ + + + + + +
+
+
+
+
+ Hi {{App_User_Name}}, +
+
+ {{Commenter_Name}} tagged you in a comment in {{Application_Name}} in the {{Organization_Name}} organization. +
+
+ {{#Comment_Body}} +
{{ . }}
+ {{/Comment_Body}} +
+
+ Please follow this link to view and respond to the comment. +
+
+
+
+ + + + + + +
+ + + + + + +
+ Go to Comment +
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CommentUtilsTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CommentUtilsTest.java new file mode 100644 index 0000000000..4c549c3c79 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CommentUtilsTest.java @@ -0,0 +1,91 @@ +package com.appsmith.server.helpers; + +import com.appsmith.server.domains.Comment; +import org.junit.Assert; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class CommentUtilsTest { + @Test + void getCommentBody_WhenBodyIsNull_ReturnsEmptyList() { + Comment comment = new Comment(); + Assert.assertEquals(0, CommentUtils.getCommentBody(comment).size()); + } + + @Test + void getCommentBody_WhenBodyHasMultipleBlocks_ReturnsValidBodies() { + Comment.Block commentBlock1 = new Comment.Block(); + commentBlock1.setText("First line"); + Comment.Block commentBlock2 = new Comment.Block(); + commentBlock1.setText("Second line"); + + Comment.Body commentBody = new Comment.Body(); + commentBody.setBlocks(List.of(commentBlock1, commentBlock2)); + + Comment comment = new Comment(); + comment.setBody(commentBody); + + Assert.assertEquals(2, CommentUtils.getCommentBody(comment).size()); + Assert.assertEquals(commentBlock1.getText(), CommentUtils.getCommentBody(comment).get(0)); + Assert.assertEquals(commentBlock2.getText(), CommentUtils.getCommentBody(comment).get(1)); + } + + @Test + public void isUserMentioned_WhenBodyIsNull_ReturnsFalse() { + Comment comment = new Comment(); + Assert.assertFalse(CommentUtils.isUserMentioned(comment, "user@abc.com")); + } + + @Test + public void isUserMentioned_WhenBodyHasNoMention_ReturnsFalse() { + Comment.Body body = new Comment.Body(); + Comment comment = new Comment(); + comment.setBody(body); + Assert.assertFalse(CommentUtils.isUserMentioned(comment, "user@abc.com")); + + Comment.Entity entity = new Comment.Entity(); + Map entityMap = new HashMap<>(); + entityMap.put("abc", entity); + body.setEntityMap(entityMap); + comment.setBody(body); + Assert.assertFalse(CommentUtils.isUserMentioned(comment, "user@abc.com")); + } + + private Map createEntityMapForUsers(List mentionedUserNames) { + Map entityMap = new HashMap<>(); + for (String username: mentionedUserNames) { + Comment.EntityData.EntityUser entityUser = new Comment.EntityData.EntityUser(); + entityUser.setUsername(username); + Comment.EntityData.Mention mention = new Comment.EntityData.Mention(); + mention.setUser(entityUser); + + Comment.EntityData entityData = new Comment.EntityData(); + entityData.setMention(mention); + + Comment.Entity entity = new Comment.Entity(); + entity.setType("mention"); + entity.setData(entityData); + entityMap.put(username, entity); + } + return entityMap; + } + + @Test + public void isUserMentioned_WhenSomeoneIsMentioned_ReturnsCorrectValue() { + Map entityMap = createEntityMapForUsers( + List.of("1", "2", "3") + ); + Comment.Body body = new Comment.Body(); + body.setEntityMap(entityMap); + Comment comment = new Comment(); + comment.setBody(body); + + Assert.assertTrue(CommentUtils.isUserMentioned(comment, "1")); + Assert.assertTrue(CommentUtils.isUserMentioned(comment, "2")); + Assert.assertTrue(CommentUtils.isUserMentioned(comment, "3")); + Assert.assertFalse(CommentUtils.isUserMentioned(comment, "4")); + } +} \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CommentServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CommentServiceTest.java index 7c29eb748d..2a46b7195d 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CommentServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CommentServiceTest.java @@ -57,7 +57,7 @@ public class CommentServiceTest { thread.setComments(List.of( makePlainTextComment("comment one") )); - return commentService.createThread(thread); + return commentService.createThread(thread, "https://app.appsmith.com"); }) .zipWhen(thread -> commentService.getThreadsByApplicationId(thread.getApplicationId())); @@ -128,7 +128,7 @@ public class CommentServiceTest { thread.setComments(List.of( makePlainTextComment("Test Comment") )); - return commentService.createThread(thread); + return commentService.createThread(thread, "https://app.appsmith.com"); }) .flatMap(commentThread -> Mono.just(commentThread.getComments().get(0))) .cache(); @@ -163,7 +163,7 @@ public class CommentServiceTest { final CommentThread thread = new CommentThread(); thread.setApplicationId(application.getId()); thread.setComments(List.of(makePlainTextComment("Test Comment"))); - return commentService.createThread(thread); + return commentService.createThread(thread, "https://app.appsmith.com"); }) .flatMap(commentThread -> Mono.just(commentThread.getComments().get(0))) .flatMap(comment -> { @@ -202,7 +202,7 @@ public class CommentServiceTest { final CommentThread thread = new CommentThread(); thread.setApplicationId(application.getId()); thread.setComments(List.of(makePlainTextComment("Test Comment"))); - return commentService.createThread(thread); + return commentService.createThread(thread, "https://app.appsmith.com"); }) .flatMap(commentThread -> Mono.just(commentThread.getComments().get(0))) .flatMap(comment -> { diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/OrganizationServiceUnitTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/OrganizationServiceUnitTest.java index 452745efbf..076af9e999 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/OrganizationServiceUnitTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/OrganizationServiceUnitTest.java @@ -3,7 +3,6 @@ package com.appsmith.server.services; import com.appsmith.server.acl.RoleGraph; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Organization; -import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserRole; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.repositories.AssetRepository; @@ -15,18 +14,15 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.test.StepVerifier; import javax.validation.Validator; -import java.util.ArrayList; import java.util.List; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_INVITE_USERS; @@ -56,7 +52,6 @@ public class OrganizationServiceUnitTest { organizationRepository, analyticsService, pluginRepository, sessionUserService, userOrganizationService, userRepository, roleGraph, assetRepository, assetService ); - MockitoAnnotations.initMocks(this); } @Test diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EmailEventHandlerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EmailEventHandlerTest.java new file mode 100644 index 0000000000..bdb0e0eea2 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EmailEventHandlerTest.java @@ -0,0 +1,198 @@ +package com.appsmith.server.solutions; + +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.Comment; +import com.appsmith.server.domains.CommentThread; +import com.appsmith.server.domains.Organization; +import com.appsmith.server.domains.UserRole; +import com.appsmith.server.events.CommentAddedEvent; +import com.appsmith.server.events.CommentThreadClosedEvent; +import com.appsmith.server.notifications.EmailSender; +import com.appsmith.server.repositories.ApplicationRepository; +import com.appsmith.server.repositories.OrganizationRepository; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; + +@RunWith(SpringJUnit4ClassRunner.class) +public class EmailEventHandlerTest { + + private static final String COMMENT_ADDED_EMAIL_TEMPLATE = "email/commentAddedTemplate.html"; + private static final String USER_MENTIONED_EMAIL_TEMPLATE = "email/userTaggedInCommentTemplate.html"; + private static final String THREAD_RESOLVED_EMAIL_TEMPLATE = "email/commentResolvedTemplate.html"; + + @MockBean + private ApplicationEventPublisher applicationEventPublisher; + @MockBean + private EmailSender emailSender; + @MockBean + private OrganizationRepository organizationRepository; + @MockBean + private ApplicationRepository applicationRepository; + + EmailEventHandler emailEventHandler; + private Application application; + private Organization organization; + + String authorUserName = "abc"; + String originHeader = "efg"; + String applicationId = "application-id"; + String organizationId = "organization-id"; + String emailReceiverUsername = "email-receiver"; + + @Before + public void setUp() { + emailEventHandler = new EmailEventHandler( + applicationEventPublisher, emailSender, organizationRepository, applicationRepository + ); + application = new Application(); + application.setName("Test application for comment"); + application.setOrganizationId(organizationId); + organization = new Organization(); + + // add a role with email receiver username + UserRole userRole = new UserRole(); + userRole.setUsername(emailReceiverUsername); + organization.setUserRoles(List.of(userRole)); + + Mockito.when(applicationRepository.findById(applicationId)).thenReturn(Mono.just(application)); + Mockito.when(organizationRepository.findById(organizationId)).thenReturn(Mono.just(organization)); + } + + @Test + public void publish_WhenValidCommentProvided_ReturnsTrue() { + Comment comment = new Comment(); + CommentAddedEvent commentAddedEvent = new CommentAddedEvent( + authorUserName, organization, application, originHeader, comment + ); + + Mockito.doNothing().when(applicationEventPublisher).publishEvent(commentAddedEvent); + + Mono booleanMono = emailEventHandler.publish(authorUserName, applicationId, comment, originHeader); + StepVerifier.create(booleanMono).assertNext(aBoolean -> { + Assert.assertEquals(Boolean.TRUE, aBoolean); + }).verifyComplete(); + } + + @Test + public void publish_WhenValidCommentThreadProvided_ReturnsTrue() { + CommentThread commentThread = new CommentThread(); + CommentThreadClosedEvent commentThreadClosedEvent = new CommentThreadClosedEvent( + authorUserName, organization, application, originHeader, commentThread + ); + Mockito.doNothing().when(applicationEventPublisher).publishEvent(commentThreadClosedEvent); + + Mono booleanMono = emailEventHandler.publish(authorUserName, applicationId, commentThread, originHeader); + StepVerifier.create(booleanMono).assertNext(aBoolean -> { + Assert.assertEquals(Boolean.TRUE, aBoolean); + }).verifyComplete(); + } + + @Test + public void handle_WhenValidCommentAddedEvent_ReturnsTrue() { + Comment sampleComment = new Comment(); + sampleComment.setAuthorUsername(authorUserName); + sampleComment.setAuthorName("Test Author"); + + // send the event + CommentAddedEvent commentAddedEvent = new CommentAddedEvent( + authorUserName, organization, application, originHeader, sampleComment + ); + emailEventHandler.handle(commentAddedEvent); + + String expectedEmailSubject = String.format( + "New comment from %s in %s", sampleComment.getAuthorName(), application.getName() + ); + // check email sender was called with expected template and subject + Mockito.verify(emailSender, Mockito.times(1)).sendMail( + eq(emailReceiverUsername), eq(expectedEmailSubject), eq(COMMENT_ADDED_EMAIL_TEMPLATE), Mockito.anyMap() + ); + } + + private Map createEntityMapForUsers(List mentionedUserNames) { + Map entityMap = new HashMap<>(); + for (String username: mentionedUserNames) { + Comment.EntityData.EntityUser entityUser = new Comment.EntityData.EntityUser(); + entityUser.setUsername(username); + Comment.EntityData.Mention mention = new Comment.EntityData.Mention(); + mention.setUser(entityUser); + + Comment.EntityData entityData = new Comment.EntityData(); + entityData.setMention(mention); + + Comment.Entity entity = new Comment.Entity(); + entity.setType("mention"); + entity.setData(entityData); + entityMap.put(username, entity); + } + return entityMap; + } + + @Test + public void handle_WhenUserMentionedEvent_ReturnsTrue() { + Comment sampleComment = new Comment(); + sampleComment.setAuthorUsername(authorUserName); + sampleComment.setAuthorName("Test Author"); + + // mention the emailReceiverUsername in the sample comment + Map entityMap = createEntityMapForUsers(List.of(emailReceiverUsername)); + Comment.Body body = new Comment.Body(); + body.setEntityMap(entityMap); + sampleComment.setBody(body); + + // send the event + CommentAddedEvent commentAddedEvent = new CommentAddedEvent( + authorUserName, organization, application, originHeader, sampleComment + ); + emailEventHandler.handle(commentAddedEvent); + + // check if expectation meets + String expectedEmailSubject = String.format("New comment for you from %s", sampleComment.getAuthorName()); + + // check email sender was called with expected template and subject + Mockito.verify(emailSender, Mockito.times(1)).sendMail( + eq(emailReceiverUsername), eq(expectedEmailSubject), eq(USER_MENTIONED_EMAIL_TEMPLATE), Mockito.anyMap() + ); + } + + @Test + public void handle_WhenThreadClosed_ReturnsTrue() { + // add comment thread with a resolved state where resolver is `authorUserName` + String resolverName = "Test Author"; + CommentThread.CommentThreadState resolveState = new CommentThread.CommentThreadState(); + resolveState.setAuthorUsername(authorUserName); + resolveState.setAuthorName(resolverName); + resolveState.setActive(true); + + CommentThread commentThread = new CommentThread(); + commentThread.setResolvedState(resolveState); + + // send the event + CommentThreadClosedEvent commentAddedEvent = new CommentThreadClosedEvent( + authorUserName, organization, application, originHeader, commentThread + ); + emailEventHandler.handle(commentAddedEvent); + + // check if expectation meets + String expectedEmailSubject = String.format( + "%s has resolved comment in %s", resolveState.getAuthorName(), application.getName() + ); + // check email sender was called with expected template and subject + Mockito.verify(emailSender, Mockito.times(1)).sendMail( + eq(emailReceiverUsername), eq(expectedEmailSubject), eq(THREAD_RESOLVED_EMAIL_TEMPLATE), Mockito.anyMap() + ); + } +} \ No newline at end of file