From 8fcbb6e4c1cf91f33c078aa5461a6364286b01f3 Mon Sep 17 00:00:00 2001 From: Nayan <83352306+nayan-rafiq@users.noreply.github.com> Date: Thu, 15 Jul 2021 23:45:08 +0600 Subject: [PATCH] Add a bot thread when someone adds first comment or tags someone is the comment for the first time (#5521) * resolved conflicts * -fixed some issues in PR * -used sequence in generating thread number * -refactored the create thread method * -refactored comment service * -add bot reply to the first comment thread of an user * -added a new bot thread when user resolves the first bot thread * -handle the case when user data can be absent * -add organization id and widget type to comment and comment threads * -resolved conflicts * -mark appsmith bot username as a tagged user in the bot comment * -turn a private thread to a public thread when someone is tagged in it * -updated as per the review comments * -fixed compile error in unit test * -updated as per PR review comments * -removed commented code --- .../server/configurations/EmailConfig.java | 3 + .../appsmith/server/constants/Appsmith.java | 1 - .../server/constants/CommentBotEvent.java | 5 + .../server/constants/CommentConstants.java | 6 + .../server/domains/AbstractCommentDomain.java | 21 + .../com/appsmith/server/domains/Comment.java | 27 +- .../server/domains/CommentThread.java | 21 +- .../com/appsmith/server/domains/UserData.java | 4 + .../appsmith/server/helpers/CommentUtils.java | 44 +- .../appsmith/server/helpers/PolicyUtils.java | 26 +- .../server/helpers/TemplateUtils.java | 28 + .../server/notifications/EmailSender.java | 24 +- .../CustomCommentThreadRepository.java | 1 + .../CustomCommentThreadRepositoryImpl.java | 10 + .../server/services/CommentServiceImpl.java | 502 ++++++++++++------ .../server/solutions/EmailEventHandler.java | 62 ++- .../src/main/resources/application.properties | 1 + .../src/main/resources/bot/howToTagBot.html | 2 + .../src/main/resources/bot/howToTagUser.html | 2 + .../server/helpers/CommentUtilsTest.java | 39 +- ...CustomCommentThreadRepositoryImplTest.java | 113 ++++ .../services/CurlImporterServiceTest.java | 1 - .../solutions/EmailEventHandlerTest.java | 5 +- 23 files changed, 692 insertions(+), 256 deletions(-) create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentBotEvent.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentConstants.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AbstractCommentDomain.java create mode 100644 app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/TemplateUtils.java create mode 100644 app/server/appsmith-server/src/main/resources/bot/howToTagBot.html create mode 100644 app/server/appsmith-server/src/main/resources/bot/howToTagUser.html create mode 100644 app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImplTest.java diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/EmailConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/EmailConfig.java index 1a086644f8..f600d442c4 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/EmailConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/EmailConfig.java @@ -22,4 +22,7 @@ public class EmailConfig { @Value("${emails.welcome.enabled:true}") private boolean isWelcomeEmailEnabled; + @Value("${mail.support}") + private String supportEmailAddress; + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Appsmith.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Appsmith.java index eb444369d9..fe8d85163c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Appsmith.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Appsmith.java @@ -1,6 +1,5 @@ package com.appsmith.server.constants; public class Appsmith { - public final static String APPSMITH_REGISTERED = "appsmith_registered"; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentBotEvent.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentBotEvent.java new file mode 100644 index 0000000000..e86fd39d7e --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentBotEvent.java @@ -0,0 +1,5 @@ +package com.appsmith.server.constants; + +public enum CommentBotEvent { + COMMENTED, RESOLVED +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentConstants.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentConstants.java new file mode 100644 index 0000000000..cc90493d2a --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/CommentConstants.java @@ -0,0 +1,6 @@ +package com.appsmith.server.constants; + +public class CommentConstants { + public final static String APPSMITH_BOT_NAME = "Appsmith Bot"; + public final static String APPSMITH_BOT_USERNAME = "appsmith"; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AbstractCommentDomain.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AbstractCommentDomain.java new file mode 100644 index 0000000000..7248c6d4a3 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AbstractCommentDomain.java @@ -0,0 +1,21 @@ +package com.appsmith.server.domains; + +import com.appsmith.external.models.BaseDomain; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public abstract class AbstractCommentDomain extends BaseDomain { + String pageId; + String applicationId; + String applicationName; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + String authorName; // Display name of the user, who authored this comment or thread. + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + String authorUsername; // username i.e. email of the user, who authored this comment or thread. + String orgId; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java index 165b8ec281..157b87ef7c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Comment.java @@ -1,11 +1,11 @@ package com.appsmith.server.domains; -import com.appsmith.external.models.BaseDomain; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; import org.springframework.data.mongodb.core.mapping.Document; import java.util.Date; @@ -17,7 +17,7 @@ import static com.appsmith.server.helpers.DateUtils.ISO_FORMATTER; @Data @EqualsAndHashCode(callSuper = false) @Document -public class Comment extends BaseDomain { +public class Comment extends AbstractCommentDomain { String threadId; @@ -27,19 +27,6 @@ public class Comment extends BaseDomain { @JsonIgnore String authorId; - /** - * Display name of the user, who authored this comment. - */ - @JsonProperty(access = JsonProperty.Access.READ_ONLY) - String authorName; - - @JsonProperty(access = JsonProperty.Access.READ_ONLY) - String authorUsername; - - private String applicationId; - private String applicationName; - private String pageId; - Body body; /** Edit/Published Mode */ @@ -70,6 +57,8 @@ public class Comment extends BaseDomain { } @Data + @AllArgsConstructor + @NoArgsConstructor public static class Range { Integer offset; Integer length; @@ -84,16 +73,22 @@ public class Comment extends BaseDomain { } @Data + @AllArgsConstructor + @NoArgsConstructor public static class EntityData { Mention mention; @Data + @AllArgsConstructor + @NoArgsConstructor public static class Mention { String name; EntityUser user; } @Data + @AllArgsConstructor + @NoArgsConstructor public static class EntityUser { String username; String roleName; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java index 7e01fe72fc..d9039d03df 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThread.java @@ -1,8 +1,6 @@ package com.appsmith.server.domains; -import com.appsmith.external.models.BaseDomain; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.data.annotation.Transient; @@ -17,7 +15,9 @@ import static com.appsmith.server.helpers.DateUtils.ISO_FORMATTER; @Data @EqualsAndHashCode(callSuper = false) @Document -public class CommentThread extends BaseDomain { +public class CommentThread extends AbstractCommentDomain { + + Boolean isPrivate; String tabId; @@ -25,7 +25,7 @@ public class CommentThread extends BaseDomain { String refId; - String pageId; + String widgetType; CommentThreadState pinnedState; @@ -33,10 +33,6 @@ public class CommentThread extends BaseDomain { String sequenceId; - String applicationId; - - String applicationName; - @JsonIgnore Set viewedByUsers; @@ -49,15 +45,6 @@ public class CommentThread extends BaseDomain { /** Edit/Published Mode */ String mode; - /** - * Display name of the user, who authored this comment thread. - */ - @JsonProperty(access = JsonProperty.Access.READ_ONLY) - String authorName; - - @JsonProperty(access = JsonProperty.Access.READ_ONLY) - String authorUsername; - @Transient Boolean isViewed; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java index f61ebf9756..55e36368d0 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java @@ -1,6 +1,7 @@ package com.appsmith.server.domains; import com.appsmith.external.models.BaseDomain; +import com.appsmith.server.constants.CommentBotEvent; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,6 +33,9 @@ public class UserData extends BaseDomain { // list of organisation ids that were recently accessed by the user private List recentlyUsedOrgIds; + // last event triggered by comment bot for this user + private CommentBotEvent latestCommentEvent; + public UserData(String userId) { this.userId = userId; } 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 index e7298f422c..700ddda4a3 100644 --- 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 @@ -1,6 +1,8 @@ package com.appsmith.server.helpers; +import com.appsmith.server.constants.CommentConstants; import com.appsmith.server.domains.Comment; +import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.HashSet; @@ -22,8 +24,32 @@ public class CommentUtils { && 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)) { + String mentionedUsername = getMentionedUsername(commentEntity.getData().getMention()); + if(userEmail.equals(mentionedUsername)) { + return true; + } + } + } + } + } + return false; + } + + /** + * Checks if anyone except the bot is mentioned in this comment + * @param comment Comment + * @return true if comment has someone in mention, false otherwise + */ + public static boolean isAnyoneMentioned(Comment comment) { + 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) { + String mentionedUsername = getMentionedUsername(commentEntity.getData().getMention()); + if(!StringUtils.isEmpty(mentionedUsername) && !CommentConstants.APPSMITH_BOT_USERNAME.equals(mentionedUsername)) { return true; } } @@ -53,8 +79,10 @@ public class CommentUtils { && 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(); - usernamesSet.add(mention.getUser().getUsername()); + String mentionedUsername = getMentionedUsername(commentEntity.getData().getMention()); + if(!StringUtils.isEmpty(mentionedUsername) && !mentionedUsername.equals(CommentConstants.APPSMITH_BOT_USERNAME)) { + usernamesSet.add(mentionedUsername); + } } } } @@ -71,4 +99,12 @@ public class CommentUtils { } return commentLines; } + + private static String getMentionedUsername(Comment.EntityData.Mention mention){ + if(mention.getUser() != null) { + return mention.getUser().getUsername(); + } else { + return mention.getName(); + } + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/PolicyUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/PolicyUtils.java index 979345fa3d..3232a9d985 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/PolicyUtils.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/PolicyUtils.java @@ -15,7 +15,6 @@ import com.appsmith.server.repositories.CommentThreadRepository; import com.appsmith.server.repositories.DatasourceRepository; import com.appsmith.server.repositories.NewActionRepository; import com.appsmith.server.repositories.NewPageRepository; -import com.appsmith.server.solutions.UserChangedHandler; import lombok.AllArgsConstructor; import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; @@ -44,7 +43,6 @@ public class PolicyUtils { private final DatasourceRepository datasourceRepository; private final NewPageRepository newPageRepository; private final NewActionRepository newActionRepository; - private final UserChangedHandler userChangedHandler; private final CommentThreadRepository commentThreadRepository; public T addPoliciesToExistingObject(Map policyMap, T obj) { @@ -110,13 +108,17 @@ public class PolicyUtils { * @return */ public Map generatePolicyFromPermission(Set permissions, User user) { + return generatePolicyFromPermission(permissions, user.getUsername()); + } + + public Map generatePolicyFromPermission(Set permissions, String username) { return permissions.stream() .map(perm -> { // Create a policy for the invited user using the permission as per the role Policy policyWithCurrentPermission = Policy.builder().permission(perm.getValue()) - .users(Set.of(user.getUsername())).build(); + .users(Set.of(username)).build(); // Generate any and all lateral policies that might come with the current permission - Set policiesForUser = policyGenerator.getLateralPolicies(perm, Set.of(user.getUsername()), null); + Set policiesForUser = policyGenerator.getLateralPolicies(perm, Set.of(username), null); policiesForUser.add(policyWithCurrentPermission); return policiesForUser; }) @@ -219,21 +221,25 @@ public class PolicyUtils { .saveAll(updatedPages)); } - public Flux updateWithApplicationPermissionsToAllItsCommentThreads(String applicationId, Map commentThreadPolicyMap, boolean addPolicyToObject) { + public Flux updateWithApplicationPermissionsToAllItsCommentThreads( + String applicationId, Map commentThreadPolicyMap, boolean addPolicyToObject) { return // fetch comment threads with read permissions commentThreadRepository.findByApplicationId(applicationId, AclPermission.READ_THREAD) .switchIfEmpty(Mono.empty()) .map(thread -> { - if (addPolicyToObject) { - return addPoliciesToExistingObject(commentThreadPolicyMap, thread); - } else { - return removePoliciesFromExistingObject(commentThreadPolicyMap, thread); + if(!Boolean.TRUE.equals(thread.getIsPrivate())) { + if (addPolicyToObject) { + return addPoliciesToExistingObject(commentThreadPolicyMap, thread); + } else { + return removePoliciesFromExistingObject(commentThreadPolicyMap, thread); + } } + return thread; }) .collectList() - .flatMapMany(commentThreads -> commentThreadRepository.saveAll(commentThreads)); + .flatMapMany(commentThreadRepository::saveAll); } /** diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/TemplateUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/TemplateUtils.java new file mode 100644 index 0000000000..48535dc9a8 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/TemplateUtils.java @@ -0,0 +1,28 @@ +package com.appsmith.server.helpers; + +import com.github.mustachejava.DefaultMustacheFactory; +import com.github.mustachejava.Mustache; +import com.github.mustachejava.MustacheFactory; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Map; + +public class TemplateUtils { + /** + * This function replaces the variables in an email template to actual values. It uses the Mustache SDK. + * + * @param template The name of the template where the HTML text can be found + * @param params A Map of key-value pairs with the key being the variable in the template & value being the actual + * value with which it must be replaced. + * @return Template string with Mustache replacements applied. + * @throws IOException bubbled from Mustache renderer. + */ + public static String parseTemplate(String template, Map params) throws IOException { + MustacheFactory mf = new DefaultMustacheFactory(); + StringWriter stringWriter = new StringWriter(); + Mustache mustache = mf.compile(template); + mustache.execute(stringWriter, params).flush(); + return stringWriter.toString(); + } +} 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 c90ad40cf8..29852477d6 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 @@ -1,9 +1,7 @@ package com.appsmith.server.notifications; import com.appsmith.server.configurations.EmailConfig; -import com.github.mustachejava.DefaultMustacheFactory; -import com.github.mustachejava.Mustache; -import com.github.mustachejava.MustacheFactory; +import com.appsmith.server.helpers.TemplateUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.mail.MailException; import org.springframework.mail.javamail.JavaMailSender; @@ -17,7 +15,6 @@ import javax.mail.MessagingException; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; import java.io.IOException; -import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.util.Map; @@ -54,7 +51,7 @@ public class EmailSender { */ Mono.fromCallable(() -> { try { - return replaceEmailTemplate(text, params); + return TemplateUtils.parseTemplate(text, params); } catch (IOException e) { throw Exceptions.propagate(e); } @@ -113,23 +110,6 @@ public class EmailSender { } } - /** - * This function replaces the variables in an email template to actual values. It uses the Mustache SDK. - * - * @param template The name of the template where the HTML text can be found - * @param params A Map of key-value pairs with the key being the variable in the template & value being the actual - * value with which it must be replaced. - * @return Template string with Mustache replacements applied. - * @throws IOException bubbled from Mustache renderer. - */ - private String replaceEmailTemplate(String template, Map params) throws IOException { - MustacheFactory mf = new DefaultMustacheFactory(); - StringWriter stringWriter = new StringWriter(); - Mustache mustache = mf.compile(template); - mustache.execute(stringWriter, params).flush(); - return stringWriter.toString(); - } - private InternetAddress makeFromAddress() { try { return new InternetAddress(this.emailConfig.getMailFrom(), "Appsmith"); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepository.java index 9587950a62..478c865d9a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepository.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepository.java @@ -11,5 +11,6 @@ import java.util.Set; public interface CustomCommentThreadRepository extends AppsmithRepository { Flux findByApplicationId(String applicationId, AclPermission permission); Mono addToSubscribers(String threadId, Set usernames); + Mono findPrivateThread(String applicationId); Mono countUnreadThreads(String applicationId, String userEmail); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImpl.java index ac943af1b1..24904b3452 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImpl.java @@ -17,6 +17,7 @@ import reactor.core.publisher.Mono; import java.util.List; import java.util.Set; +import static java.lang.Boolean.TRUE; import static org.springframework.data.mongodb.core.query.Criteria.where; @Component @@ -47,6 +48,15 @@ public class CustomCommentThreadRepositoryImpl extends BaseAppsmithRepositoryImp ); } + @Override + public Mono findPrivateThread(String applicationId) { + List criteria = List.of( + where(fieldName(QCommentThread.commentThread.applicationId)).is(applicationId), + where(fieldName(QCommentThread.commentThread.isPrivate)).is(TRUE) + ); + return queryOne(criteria, AclPermission.READ_THREAD); + } + @Override public Mono countUnreadThreads(String applicationId, String userEmail) { List criteriaList = List.of( 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 c724b41c78..b9b9078d73 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 @@ -3,34 +3,39 @@ package com.appsmith.server.services; import com.appsmith.external.models.Policy; import com.appsmith.server.acl.AclPermission; import com.appsmith.server.acl.PolicyGenerator; +import com.appsmith.server.constants.CommentBotEvent; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Comment; import com.appsmith.server.domains.CommentThread; import com.appsmith.server.domains.Notification; import com.appsmith.server.domains.User; +import com.appsmith.server.domains.UserData; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.helpers.CommentUtils; import com.appsmith.server.helpers.PolicyUtils; +import com.appsmith.server.helpers.TemplateUtils; import com.appsmith.server.repositories.CommentRepository; import com.appsmith.server.repositories.CommentThreadRepository; +import com.appsmith.server.repositories.UserDataRepository; import com.appsmith.server.solutions.EmailEventHandler; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Service; +import reactor.core.Exceptions; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import javax.validation.Validator; +import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -39,11 +44,20 @@ import java.util.List; import java.util.Map; import java.util.Set; +import static com.appsmith.server.constants.CommentConstants.APPSMITH_BOT_NAME; +import static com.appsmith.server.constants.CommentConstants.APPSMITH_BOT_USERNAME; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + @Slf4j @Service public class CommentServiceImpl extends BaseService implements CommentService { + private static final String HOW_TO_TAG_USER_COMMENT = "bot/howToTagUser.html"; + private static final String HOW_TO_TAG_BOT_COMMENT = "bot/howToTagBot.html"; + private final CommentThreadRepository threadRepository; + private final UserDataRepository userDataRepository; private final UserService userService; private final SessionUserService sessionUserService; @@ -53,6 +67,7 @@ public class CommentServiceImpl extends BaseService create(String threadId, Comment comment, String originHeader) { - return create(threadId, comment, originHeader, true); - } - - 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(); } - final Mono userMono = sessionUserService.getCurrentUser() - .flatMap(user -> { - if (user.getId() == null) { - return userService.findByEmail(user.getEmail()); - } else { - return Mono.just(user); + final Mono userMono = sessionUserService.getCurrentUser().flatMap(user -> { + if (user.getId() == null) { + return userService.findByEmail(user.getEmail()); + } else { + return Mono.just(user); + } + }); + + Mono commentThreadMono = threadRepository.findById(threadId, AclPermission.COMMENT_ON_THREAD) + .flatMap(commentThread -> { + if (CommentUtils.isAnyoneMentioned(comment) && Boolean.TRUE.equals(commentThread.getIsPrivate())) { + return convertToPublic(commentThread); } + return Mono.just(commentThread); }); - final Mono threadMono = threadRepository.findById(threadId, AclPermission.COMMENT_ON_THREAD); - return Mono.zip(userMono, threadMono) + return userMono.zipWith(commentThreadMono) .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.ACL_NO_RESOURCE_FOUND, "comment thread", threadId))) .flatMap(tuple -> { final User user = tuple.getT1(); final CommentThread thread = tuple.getT2(); - - comment.setAuthorId(user.getId()); - comment.setThreadId(threadId); - comment.setApplicationId(thread.getApplicationId()); - comment.setApplicationName(thread.getApplicationName()); - comment.setPageId(thread.getPageId()); - - final Set policies = policyGenerator.getAllChildPolicies( - thread.getPolicies(), - CommentThread.class, - Comment.class - ); - policies.add(policyUtils.generatePolicyFromPermission( - Set.of(AclPermission.MANAGE_COMMENT), - user - ).get(AclPermission.MANAGE_COMMENT.getValue())); - comment.setPolicies(policies); - - String authorName = user.getName() != null ? user.getName(): user.getUsername(); - comment.setAuthorUsername(user.getUsername()); - comment.setAuthorName(authorName); - Set subscribersFromThisComment = CommentUtils.getSubscriberUsernames(comment); - - // add them to current thread so that we don't need to query again - if(thread.getSubscribers() != null) { - thread.getSubscribers().addAll(subscribersFromThisComment); - } else { - thread.setSubscribers(subscribersFromThisComment); - } - - return Mono.zip( - Mono.just(user), - Mono.just(thread), - repository.save(comment), - threadRepository.addToSubscribers(threadId, subscribersFromThisComment) - ); - }) - .flatMap(tuple -> { - final User user = tuple.getT1(); - CommentThread commentThread = tuple.getT2(); - final Comment savedComment = tuple.getT3(); - Mono publishEmailMono = emailEventHandler.publish( - comment.getAuthorUsername(), - commentThread.getApplicationId(), - comment, - originHeader, - commentThread.getSubscribers() - ); - - if (shouldCreateNotification) { - final Set usernames = commentThread.getSubscribers(); - List> notificationMonos = new ArrayList<>(); - for (String username : usernames) { - if (!username.equals(user.getUsername())) { - Mono notificationMono = notificationService.createNotification( - savedComment, username - ); - notificationMonos.add(notificationMono); - } - } - return Flux.concat(notificationMonos).then(publishEmailMono).thenReturn(savedComment); - } else { - return publishEmailMono.thenReturn(savedComment); - } + return create(thread, user, comment, originHeader, true); }); } + /** + * Converts a private bot thread to a public thread. + * It sets the isPrivate flag to false, changes the sequence and updates the policy + * @param commentThread + * @return + */ + private Mono convertToPublic(CommentThread commentThread) { + return applicationService.findById(commentThread.getApplicationId()) + .zipWith(sequenceService.getNext(CommentThread.class, commentThread.getApplicationId())) + .flatMap(objects -> { + Application application = objects.getT1(); + commentThread.setSequenceId("#" + objects.getT2()); + commentThread.setIsPrivate(FALSE); + final Set policies = new HashSet<>(); + policies.addAll(policyGenerator.getAllChildPolicies( + application.getPolicies(), + Application.class, + CommentThread.class + )); + policies.add(policyUtils.generatePolicyFromPermission( + Set.of(AclPermission.MANAGE_THREAD), + commentThread.getAuthorUsername() + ).get(AclPermission.MANAGE_THREAD.getValue())); + commentThread.setPolicies(policies); + return threadRepository.save(commentThread); + }); + } + + private Mono create(CommentThread commentThread, User user, Comment comment, String originHeader, boolean shouldCreateNotification) { + comment.setAuthorId(user.getId()); + comment.setThreadId(commentThread.getId()); + comment.setApplicationId(commentThread.getApplicationId()); + comment.setApplicationName(commentThread.getApplicationName()); + comment.setPageId(commentThread.getPageId()); + comment.setOrgId(commentThread.getOrgId()); + + final Set policies = policyGenerator.getAllChildPolicies( + commentThread.getPolicies(), + CommentThread.class, + Comment.class + ); + policies.add(policyUtils.generatePolicyFromPermission( + Set.of(AclPermission.MANAGE_COMMENT), + user + ).get(AclPermission.MANAGE_COMMENT.getValue())); + comment.setPolicies(policies); + + String authorName = user.getName() != null ? user.getName() : user.getUsername(); + comment.setAuthorUsername(user.getUsername()); + comment.setAuthorName(authorName); + + Mono commentMono; + if (!TRUE.equals(commentThread.getIsPrivate())) { + Set subscribersFromThisComment = CommentUtils.getSubscriberUsernames(comment); + // add them to current thread so that we don't need to query again + if (commentThread.getSubscribers() != null) { + commentThread.getSubscribers().addAll(subscribersFromThisComment); + } else { + commentThread.setSubscribers(subscribersFromThisComment); + } + commentMono = threadRepository.addToSubscribers(commentThread.getId(), subscribersFromThisComment) + .then(repository.save(comment)); + } else { + commentMono = repository.save(comment); + } + + return commentMono.flatMap(savedComment -> { + boolean isPrivateThread = TRUE.equals(commentThread.getIsPrivate()); + Mono publishEmail = emailEventHandler.publish( + comment.getAuthorUsername(), + commentThread.getApplicationId(), + comment, + originHeader, + commentThread.getSubscribers() + ); + + if (shouldCreateNotification && !isPrivateThread) { + final Set usernames = commentThread.getSubscribers(); + List> notificationMonos = new ArrayList<>(); + for (String username : usernames) { + if (!username.equals(user.getUsername()) && !username.equals(APPSMITH_BOT_USERNAME)) { + Mono notificationMono = notificationService.createNotification( + savedComment, username + ); + notificationMonos.add(notificationMono); + } + } + return publishEmail.then(Flux.merge(notificationMonos).then(Mono.just(savedComment))); + } else { + return publishEmail.thenReturn(savedComment); + } + }); + } + @Override public Mono createThread(CommentThread commentThread, String originHeader) { // 1. Check if this user has permission on the application given by `commentThread.applicationId`. @@ -181,79 +232,61 @@ public class CommentServiceImpl extends BaseService { - count += 1; - commentThread.setSequenceId("#" + count); - return Mono.zip( - sessionUserService.getCurrentUser(), - applicationService.findById(applicationId, AclPermission.COMMENT_ON_APPLICATIONS) - ); - }) - .switchIfEmpty(Mono.error(new AppsmithException( - AppsmithError.ACL_NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId) - )) - .flatMap(tuple -> { - final User user = tuple.getT1(); - final Application application = tuple.getT2(); - commentThread.setApplicationName(application.getName()); - commentThread.setAuthorName(user.getName()); - commentThread.setAuthorUsername(user.getUsername()); - - final Set policies = policyGenerator.getAllChildPolicies( - application.getPolicies(), - Application.class, - CommentThread.class - ); - policies.add(policyUtils.generatePolicyFromPermission( - Set.of(AclPermission.MANAGE_THREAD), - user - ).get(AclPermission.MANAGE_THREAD.getValue())); - - commentThread.setPolicies(policies); - - Set viewedUser = new HashSet<>(); - viewedUser.add(user.getUsername()); - commentThread.setViewedByUsers(viewedUser); - return threadRepository.save(commentThread); - }) - .flatMapMany(thread -> { - List> commentSaverMonos = new ArrayList<>(); - - if (!CollectionUtils.isEmpty(thread.getComments())) { - thread.getComments().get(0).setLeading(true); - boolean isFirst = true; - for (final Comment comment : thread.getComments()) { - comment.setId(null); - commentSaverMonos.add(create(thread.getId(), comment, originHeader, !isFirst)); - isFirst = false; + final Mono userMono = sessionUserService.getCurrentUser().flatMap(user -> { + if (user.getId() == null) { + return userService.findByEmail(user.getEmail()); + } else { + return Mono.just(user); + } + }); + return userMono.flatMap(user -> { + return userDataRepository.findByUserId(user.getId()) + .defaultIfEmpty(new UserData(user.getId())) + .zipWith(applicationService.findById(applicationId, AclPermission.COMMENT_ON_APPLICATIONS)) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId) + )) + .flatMap(tuple -> { + final UserData userData = tuple.getT1(); + final Application application = tuple.getT2(); + // check whether this thread should be converted to bot thread + if (userData.getLatestCommentEvent() == null) { + commentThread.setIsPrivate(true); + userData.setLatestCommentEvent(CommentBotEvent.COMMENTED); + return userDataRepository.save(userData).then( + saveCommentThread(commentThread, application, user) + ); } - } + return saveCommentThread(commentThread, application, user); + }) + .flatMapMany(thread -> { + List> commentSaverMonos = new ArrayList<>(); - // Using `concat` here so that the comments are saved one after the other, so that their `createdAt` - // value is meaningful. - return Flux.concat(commentSaverMonos); - }) - .collectList() - .zipWith(sessionUserService.getCurrentUser()) - .map(tuple -> { - final List comments = tuple.getT1(); - commentThread.setComments(comments); - commentThread.setIsViewed(true); - return commentThread; - }); + if (!CollectionUtils.isEmpty(thread.getComments())) { + thread.getComments().get(0).setLeading(true); + boolean isFirst = true; + for (final Comment comment : thread.getComments()) { + comment.setId(null); + commentSaverMonos.add(create(thread, user, comment, originHeader, !isFirst)); + isFirst = false; + } + } + + if (TRUE.equals(thread.getIsPrivate())) { + // this is the first thread by this user, add a bot comment also + commentSaverMonos.add(createBotComment(thread, user, CommentBotEvent.COMMENTED)); + } + // Using `concat` here so that the comments are saved one after the other, so that their `createdAt` + // value is meaningful. + return Flux.concat(commentSaverMonos); + }) + .collectList() + .map(commentList -> { + commentThread.setComments(commentList); + commentThread.setIsViewed(true); + return commentThread; + }); + }); } @Override @@ -264,17 +297,19 @@ public class CommentServiceImpl extends BaseService 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. - threadRepository.findById(threadId, AclPermission.READ_THREAD) - ) + return sessionUserService.getCurrentUser().flatMap(user -> { + if (user.getId() == null) { + return userService.findByEmail(user.getEmail()); + } else { + return Mono.just(user); + } + }).zipWith(threadRepository.findById(threadId, AclPermission.READ_THREAD)) .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.ACL_NO_RESOURCE_FOUND, "comment thread", threadId))) .flatMap(tuple -> { final User user = tuple.getT1(); final CommentThread threadFromDb = tuple.getT2(); - String authorName = user.getName() != null ? user.getName(): user.getUsername(); + String authorName = user.getName() != null ? user.getName() : user.getUsername(); if (commentThread.getResolvedState() != null) { CommentThread.CommentThreadState state = new CommentThread.CommentThreadState(); @@ -313,20 +348,58 @@ public class CommentServiceImpl extends BaseService triggerBotThreadResolved(CommentThread resolvedThread, User user) { + return userDataRepository.findByUserId(user.getId()) + .defaultIfEmpty(new UserData(user.getId())) + .flatMap(userData -> { + if (userData.getLatestCommentEvent() == CommentBotEvent.COMMENTED) { + // update the user data + userData.setLatestCommentEvent(CommentBotEvent.RESOLVED); + Mono saveUserDataMono = userDataRepository.save(userData); + + Mono saveThreadMono = applicationService.getById(resolvedThread.getApplicationId()) + .flatMap(application -> { + // create a new bot thread + CommentThread commentThread = new CommentThread(); + commentThread.setIsPrivate(true); + CommentThread.Position position = new CommentThread.Position(); + position.setTop(0.558882236480713f); + position.setLeft(73.5241470336914f); + commentThread.setPosition(position); + commentThread.setPageId(resolvedThread.getPageId()); + commentThread.setRefId(resolvedThread.getRefId()); + commentThread.setMode(resolvedThread.getMode()); + + return saveCommentThread(commentThread, application, user) + .flatMap(savedCommentThread -> + createBotComment(savedCommentThread, user, CommentBotEvent.RESOLVED) + .thenReturn(savedCommentThread) + ); + }); + + return saveUserDataMono.then(saveThreadMono).thenReturn(TRUE); + } + return Mono.just(FALSE); + }); + } + @Override public Mono> getThreadsByApplicationId(String applicationId) { return threadRepository.findByApplicationId(applicationId, AclPermission.READ_THREAD) @@ -342,7 +415,7 @@ public class CommentServiceImpl extends BaseService()); - if(thread.getViewedByUsers() != null && thread.getViewedByUsers().contains(user.getUsername())) { + if (thread.getViewedByUsers() != null && thread.getViewedByUsers().contains(user.getUsername())) { thread.setIsViewed(true); } else { thread.setIsViewed(false); @@ -414,6 +487,103 @@ public class CommentServiceImpl extends BaseService saveCommentThread(CommentThread commentThread, Application application, User user) { + CommentThread.CommentThreadState initState = new CommentThread.CommentThreadState(); + initState.setActive(false); + initState.setAuthorName(""); + initState.setAuthorUsername(""); + + commentThread.setOrgId(application.getOrganizationId()); + commentThread.setPinnedState(initState); + commentThread.setResolvedState(initState); + commentThread.setApplicationId(application.getId()); + commentThread.setApplicationName(application.getName()); + + commentThread.setAuthorName(user.getName()); + commentThread.setAuthorUsername(user.getUsername()); + commentThread.setViewedByUsers(Set.of(user.getUsername())); + + final Set policies = new HashSet<>(); + Mono commentSeq; + if (TRUE.equals(commentThread.getIsPrivate())) { + Collection policyCollection = policyUtils.generatePolicyFromPermission( + Set.of(AclPermission.MANAGE_THREAD, AclPermission.COMMENT_ON_THREAD), + user + ).values(); + policies.addAll(policyCollection); + commentSeq = Mono.just(0L); + } else { + policies.addAll(policyGenerator.getAllChildPolicies( + application.getPolicies(), + Application.class, + CommentThread.class + )); + policies.add(policyUtils.generatePolicyFromPermission( + Set.of(AclPermission.MANAGE_THREAD), + user + ).get(AclPermission.MANAGE_THREAD.getValue())); + commentSeq = sequenceService.getNext(CommentThread.class, application.getId()); + } + commentThread.setPolicies(policies); + return commentSeq.map(sequenceNo -> { + commentThread.setSequenceId("#" + sequenceNo); + return sequenceNo; + }).then(threadRepository.save(commentThread)); + } + + private Mono createBotComment(CommentThread commentThread, User user, CommentBotEvent commentBotEvent) { + final Comment comment = new Comment(); + comment.setThreadId(commentThread.getId()); + comment.setAuthorName(APPSMITH_BOT_NAME); + comment.setAuthorUsername(APPSMITH_BOT_USERNAME); + comment.setApplicationId(commentThread.getApplicationId()); + comment.setOrgId(commentThread.getOrgId()); + + final Set policies = policyGenerator.getAllChildPolicies( + commentThread.getPolicies(), + CommentThread.class, + Comment.class + ); + policies.add(policyUtils.generatePolicyFromPermission( + Set.of(AclPermission.MANAGE_COMMENT), + user + ).get(AclPermission.MANAGE_COMMENT.getValue())); + comment.setPolicies(policies); + + Comment.Block block = new Comment.Block(); + Comment.Body body = new Comment.Body(); + body.setBlocks(List.of(block)); + comment.setBody(body); + + block.setKey("key1"); + Map botCommentParams = new HashMap<>(); + botCommentParams.put("AppsmithBotName", APPSMITH_BOT_NAME); + botCommentParams.put("AppsmithBotUserName", APPSMITH_BOT_USERNAME); + Map entityMap = new HashMap<>(); + + try { + if (commentBotEvent == CommentBotEvent.COMMENTED) { + block.setText(TemplateUtils.parseTemplate(HOW_TO_TAG_BOT_COMMENT, botCommentParams)); + block.setEntityRanges(List.of(new Comment.Range(92, APPSMITH_BOT_USERNAME.length(), 0))); + + Comment.EntityData entityData = new Comment.EntityData(); + entityData.setMention(new Comment.EntityData.Mention("appsmith", null)); + Comment.Entity commentEntity = new Comment.Entity(); + commentEntity.setType("mention"); + commentEntity.setData(entityData); + entityMap.put("0", commentEntity); + } else { + block.setText(TemplateUtils.parseTemplate(HOW_TO_TAG_USER_COMMENT, botCommentParams)); + } + } catch (IOException e) { + throw Exceptions.propagate(e); + } + block.setType("unstyled"); + block.setDepth(0); + body.setEntityMap(entityMap); + return repository.save(comment); + } + @Override public Mono getUnreadCount(String applicationId) { return sessionUserService.getCurrentUser() 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 index 5e9e837014..e6467dc9fd 100644 --- 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 @@ -1,5 +1,7 @@ package com.appsmith.server.solutions; +import com.appsmith.server.configurations.EmailConfig; +import com.appsmith.server.constants.CommentConstants; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Comment; import com.appsmith.server.domains.CommentThread; @@ -43,6 +45,7 @@ public class EmailEventHandler { private final OrganizationRepository organizationRepository; private final ApplicationRepository applicationRepository; private final PolicyUtils policyUtils; + private final EmailConfig emailConfig; public Mono publish(String authorUserName, String applicationId, Comment comment, String originHeader, Set subscribers) { if(CollectionUtils.isEmpty(subscribers)) { // no subscriber found, return without doing anything @@ -78,7 +81,7 @@ public class EmailEventHandler { @Async @EventListener public void handle(CommentAddedEvent event) { - this.sendEmailForComment( + this.sendEmailForCommentAdded( event.getAuthorUserName(), event.getOrganization(), event.getApplication(), @@ -92,7 +95,7 @@ public class EmailEventHandler { @Async @EventListener public void handle(CommentThreadClosedEvent event) { - this.sendEmailForComment( + this.sendEmailForCommentThreadResolved( event.getAuthorUserName(), event.getOrganization(), event.getApplication(), @@ -104,9 +107,9 @@ public class EmailEventHandler { .subscribe(); } - private String getCommentThreadLink(Application application, String pageId, String threadId, UserRole userRole, String originHeader) { + private String getCommentThreadLink(Application application, String pageId, String threadId, String username, String originHeader) { Boolean canManageApplication = policyUtils.isPermissionPresentForUser( - application.getPolicies(), MANAGE_APPLICATIONS.getValue(), userRole.getUsername() + application.getPolicies(), MANAGE_APPLICATIONS.getValue(), username ); String urlPostfix = "/edit"; if (Boolean.FALSE.equals(canManageApplication)) { // user has no permission to manage application @@ -131,7 +134,7 @@ public class EmailEventHandler { application, commentThread.getPageId(), commentThread.getId(), - receiverUserRole, + receiverUserRole.getUsername(), originHeader) ); templateParams.put("Resolved", true); @@ -157,7 +160,7 @@ public class EmailEventHandler { application, comment.getPageId(), comment.getThreadId(), - receiverUserRole, + receiverUserRole.getUsername(), originHeader) ); @@ -177,17 +180,48 @@ public class EmailEventHandler { return emailSender.sendMail(receiverEmail, emailSubject, COMMENT_ADDED_EMAIL_TEMPLATE, templateParams); } - private Mono sendEmailForComment(String authorUserName, Organization organization, Application application, E commentDomain, String originHeader, Set subscribers) { + private Mono geBotEmailSenderMono(Comment comment, String originHeader, Organization organization, Application application) { + Map templateParams = new HashMap<>(); + templateParams.put("App_User_Name", CommentConstants.APPSMITH_BOT_NAME); + templateParams.put("Commenter_Name", comment.getAuthorName()); + templateParams.put("Application_Name", comment.getApplicationName()); + templateParams.put("Organization_Name", organization.getName()); + templateParams.put("Comment_Body", CommentUtils.getCommentBody(comment)); + templateParams.put("commentUrl", getCommentThreadLink( + application, + comment.getPageId(), + comment.getThreadId(), + CommentConstants.APPSMITH_BOT_USERNAME, + originHeader) + ); + + templateParams.put("Mentioned", true); + String emailSubject = String.format("New comment for you from %s", comment.getAuthorName()); + + return emailSender.sendMail( + emailConfig.getSupportEmailAddress(), emailSubject, COMMENT_ADDED_EMAIL_TEMPLATE, templateParams + ); + } + + private Mono sendEmailForCommentAdded(String authorUserName, Organization organization, Application application, Comment comment, String originHeader, Set subscribers) { List> emailMonos = new ArrayList<>(); for (UserRole userRole : organization.getUserRoles()) { if(!authorUserName.equals(userRole.getUsername()) && subscribers.contains(userRole.getUsername())) { - if(commentDomain instanceof Comment) { - Comment comment = (Comment)commentDomain; - emailMonos.add(getAddCommentEmailSenderMono(userRole, comment, originHeader, organization, application)); - } else if(commentDomain instanceof CommentThread) { - CommentThread commentThread = (CommentThread) commentDomain; - emailMonos.add(getResolveThreadEmailSenderMono(userRole, commentThread, originHeader, organization, application)); - } + emailMonos.add(getAddCommentEmailSenderMono(userRole, comment, originHeader, organization, application)); + } + } + + if(CommentUtils.isUserMentioned(comment, CommentConstants.APPSMITH_BOT_USERNAME)) { + emailMonos.add(geBotEmailSenderMono(comment, originHeader, organization, application)); + } + return Flux.concat(emailMonos).then(Mono.just(Boolean.TRUE)); + } + + private Mono sendEmailForCommentThreadResolved(String authorUserName, Organization organization, Application application, CommentThread commentThread, String originHeader, Set subscribers) { + List> emailMonos = new ArrayList<>(); + for (UserRole userRole : organization.getUserRoles()) { + if(!authorUserName.equals(userRole.getUsername()) && subscribers.contains(userRole.getUsername())) { + emailMonos.add(getResolveThreadEmailSenderMono(userRole, commentThread, originHeader, organization, application)); } } return Flux.concat(emailMonos).then(Mono.just(Boolean.TRUE)); diff --git a/app/server/appsmith-server/src/main/resources/application.properties b/app/server/appsmith-server/src/main/resources/application.properties index 1b86904aa7..938e41270e 100644 --- a/app/server/appsmith-server/src/main/resources/application.properties +++ b/app/server/appsmith-server/src/main/resources/application.properties @@ -53,6 +53,7 @@ spring.redis.url=${APPSMITH_REDIS_URL} # to send an email. mail.enabled=${APPSMITH_MAIL_ENABLED:false} mail.from=${APPSMITH_MAIL_FROM:appsmith@localhost} +mail.support=${APPSMITH_MAIL_SUPPORT:support@appsmith.com} reply.to=${APPSMITH_REPLY_TO:appsmith@localhost} spring.mail.host=${APPSMITH_MAIL_HOST:} spring.mail.port=${APPSMITH_MAIL_PORT:} diff --git a/app/server/appsmith-server/src/main/resources/bot/howToTagBot.html b/app/server/appsmith-server/src/main/resources/bot/howToTagBot.html new file mode 100644 index 0000000000..63504af749 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/bot/howToTagBot.html @@ -0,0 +1,2 @@ +Hello! 👋 I'm here to help you with your app! +You can get help from team appsmith by tagging {{AppsmithBotUserName}} in your comment. Feel free to say Hi! \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/bot/howToTagUser.html b/app/server/appsmith-server/src/main/resources/bot/howToTagUser.html new file mode 100644 index 0000000000..e77190a141 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/bot/howToTagUser.html @@ -0,0 +1,2 @@ +Apps get shipped faster when team members are involved early on! +Invite a teammate to gather requirements, brainstorm, or get some feedback using the @ symbol followed by their email. \ 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 index 42d89f99f8..92a1dc10f7 100644 --- 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 @@ -1,5 +1,6 @@ package com.appsmith.server.helpers; +import com.appsmith.server.constants.CommentConstants; import com.appsmith.server.domains.Comment; import org.junit.Assert; import org.junit.jupiter.api.Test; @@ -86,10 +87,10 @@ class CommentUtilsTest { 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")); + assertThat(CommentUtils.isUserMentioned(comment, "1")).isTrue(); + assertThat(CommentUtils.isUserMentioned(comment, "2")).isTrue(); + assertThat(CommentUtils.isUserMentioned(comment, "3")).isTrue(); + assertThat(CommentUtils.isUserMentioned(comment, "4")).isFalse(); } @Test @@ -120,4 +121,34 @@ class CommentUtilsTest { assertThat(subscriberUsernames).contains("2"); assertThat(subscriberUsernames).contains("3"); } + + @Test + void isAnyoneMentioned() { + // when comment has no body, expect false + Comment comment = new Comment(); + assertThat(CommentUtils.isAnyoneMentioned(comment)).isFalse(); + + // when comment has body but no mention, expect false + Map entityMap = createEntityMapForUsers(List.of()); + Comment.Body body = new Comment.Body(); + body.setEntityMap(entityMap); + comment.setBody(body); + assertThat(CommentUtils.isAnyoneMentioned(comment)).isFalse(); + + // when comment has body, but only bot is mentioed + Map entityMap2 = createEntityMapForUsers(List.of(CommentConstants.APPSMITH_BOT_USERNAME)); + Comment.Body body2 = new Comment.Body(); + body2.setEntityMap(entityMap2); + comment.setBody(body2); + assertThat(CommentUtils.isAnyoneMentioned(comment)).isFalse(); + + // when comment has body with bot and other user mentioned + Map entityMap3 = createEntityMapForUsers( + List.of(CommentConstants.APPSMITH_BOT_USERNAME, "2") + ); + Comment.Body body3 = new Comment.Body(); + body3.setEntityMap(entityMap3); + comment.setBody(body3); + assertThat(CommentUtils.isAnyoneMentioned(comment)).isTrue(); + } } \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImplTest.java new file mode 100644 index 0000000000..7b44d5207f --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/repositories/CustomCommentThreadRepositoryImplTest.java @@ -0,0 +1,113 @@ +package com.appsmith.server.repositories; + +import com.appsmith.external.models.Policy; +import com.appsmith.server.acl.AclPermission; +import com.appsmith.server.domains.CommentThread; +import com.appsmith.server.domains.User; +import com.appsmith.server.helpers.PolicyUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class CustomCommentThreadRepositoryImplTest { + + @Autowired + CommentThreadRepository commentThreadRepository; + + @Autowired + PolicyUtils policyUtils; + + private CommentThread createThreadWithPolicies(String userEmail) { + CommentThread thread = new CommentThread(); + User user = new User(); + user.setEmail(userEmail); + + Map policyMap = policyUtils.generatePolicyFromPermission(Set.of(AclPermission.READ_THREAD), user); + thread.setPolicies(Set.copyOf(policyMap.values())); + return thread; + } + + @Test + @WithUserDetails(value = "api_user") + public void addToSubscribers_WhenNoSubscriber_NewOnesAdded() { + CommentThread thread = createThreadWithPolicies("api_user"); + + Mono commentThreadMono = commentThreadRepository.save(thread).flatMap(savedThread -> + commentThreadRepository.addToSubscribers(savedThread.getId(), Set.of("a", "b", "c")) + .thenReturn(savedThread) + ).flatMap(commentThread -> commentThreadRepository.findById(commentThread.getId())); + + StepVerifier.create(commentThreadMono).assertNext(commentThread -> { + assertThat(commentThread.getSubscribers().size()).isEqualTo(3); + assertThat(commentThread.getSubscribers()).contains("a", "b", "c"); + }).verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void addToSubscribers_WhenSubscriberExists_NewOnesAdded() { + CommentThread thread = createThreadWithPolicies("api_user"); + + Mono commentThreadMono = commentThreadRepository.save(thread).flatMap(savedThread -> + commentThreadRepository.addToSubscribers(savedThread.getId(), Set.of("a", "b", "c", "d")) + .thenReturn(savedThread) + ).flatMap(commentThread -> commentThreadRepository.findById(commentThread.getId())); + + StepVerifier.create(commentThreadMono).assertNext(commentThread -> { + assertThat(commentThread.getSubscribers().size()).isEqualTo(4); + assertThat(commentThread.getSubscribers()).contains("a", "b", "c", "d"); + }).verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void findPrivateThread_WhenNoneExists_ReturnsEmpty() { + CommentThread thread = createThreadWithPolicies("api_user"); + thread.setApplicationId("sample-application-id-1"); + thread.setIsPrivate(true); + + Mono privateThreadMono = commentThreadRepository.save(thread) + .then(commentThreadRepository.findPrivateThread("sample-application-id-2")); + + StepVerifier.create(privateThreadMono).verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void findPrivateThread_WhenOneExists_ReturnsOne() { + CommentThread thread1 = createThreadWithPolicies("api_user"); + thread1.setApplicationId("sample-application-id-1"); + thread1.setAuthorUsername("author1"); + thread1.setIsPrivate(false); + + CommentThread thread2 = createThreadWithPolicies("api_user2"); + thread2.setApplicationId("sample-application-id-1"); + thread2.setAuthorUsername("author2"); + thread2.setIsPrivate(true); + + CommentThread thread3 = createThreadWithPolicies("api_user"); + thread3.setApplicationId("sample-application-id-1"); + thread3.setAuthorUsername("author3"); + thread3.setIsPrivate(true); + + Mono privateThreadMono = commentThreadRepository.saveAll(List.of(thread1, thread2, thread3)) + .then(commentThreadRepository.findPrivateThread("sample-application-id-1")); + + StepVerifier.create(privateThreadMono).assertNext(commentThread -> { + assertThat(commentThread.getAuthorUsername()).isEqualTo("author3"); + }).verifyComplete(); + } +} \ No newline at end of file diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CurlImporterServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CurlImporterServiceTest.java index b31d9d14eb..c4b49cc179 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CurlImporterServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/CurlImporterServiceTest.java @@ -4,7 +4,6 @@ import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.Property; import com.appsmith.external.plugins.PluginExecutor; import com.appsmith.server.acl.AclPermission; -import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.User; import com.appsmith.server.dtos.ActionDTO; 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 index e622720c72..ef59e248dd 100644 --- 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 @@ -1,6 +1,7 @@ package com.appsmith.server.solutions; import com.appsmith.server.acl.AppsmithRole; +import com.appsmith.server.configurations.EmailConfig; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Comment; import com.appsmith.server.domains.CommentThread; @@ -44,6 +45,8 @@ public class EmailEventHandlerTest { private OrganizationRepository organizationRepository; @MockBean private ApplicationRepository applicationRepository; + @MockBean + private EmailConfig emailConfig; @MockBean private PolicyUtils policyUtils; @@ -61,7 +64,7 @@ public class EmailEventHandlerTest { @Before public void setUp() { emailEventHandler = new EmailEventHandler( - applicationEventPublisher, emailSender, organizationRepository, applicationRepository, policyUtils + applicationEventPublisher, emailSender, organizationRepository, applicationRepository, policyUtils, emailConfig ); application = new Application(); application.setName("Test application for comment");