diff --git a/app/rts/src/server.ts b/app/rts/src/server.ts index 5e627d7298..27c772250c 100644 --- a/app/rts/src/server.ts +++ b/app/rts/src/server.ts @@ -233,6 +233,34 @@ async function watchMongoDB(io) { } }) + const notificationsStream = db.collection("notification").watch( + [ + // Prevent server-internal fields from being sent to the client. + { + $unset: [ + "deletedAt", + "deleted", + "_class", + ].map(f => "fullDocument." + f) + }, + ], + { fullDocument: "updateLookup" } + ); + + notificationsStream.on("change", async (event: mongodb.ChangeEventCR) => { + console.log("notification event", event) + const notification = event.fullDocument + + if (notification == null) { + // This happens when `event.operationType === "drop"`, when a notification is deleted. + console.error("Null document recieved for notification change event", event) + return + } + + const eventName = event.operationType + ":" + event.ns.coll + io.to("email:" + notification.forUsername).emit(eventName, { notification }) + }) + process.on("exit", () => { (commentChangeStream != null ? commentChangeStream.close() : Promise.bind(client).resolve()) .then(client.close.bind(client)) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Url.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Url.java index 7d4d23540d..87d8446484 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Url.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/Url.java @@ -29,4 +29,5 @@ public interface Url { String MARKETPLACE_ITEM_URL = BASE_URL + VERSION + "/items"; String ASSET_URL = BASE_URL + VERSION + "/assets"; String COMMENT_URL = BASE_URL + VERSION + "/comments"; + String NOTIFICATION_URL = BASE_URL + VERSION + "/notifications"; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java new file mode 100644 index 0000000000..b7a00dec57 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/NotificationController.java @@ -0,0 +1,21 @@ +package com.appsmith.server.controllers; + +import com.appsmith.server.constants.Url; +import com.appsmith.server.domains.Notification; +import com.appsmith.server.services.NotificationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(Url.NOTIFICATION_URL) +public class NotificationController extends BaseController { + + @Autowired + public NotificationController(NotificationService service) { + super(service); + } + +} 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 178424aac4..324b22946f 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 @@ -36,6 +36,12 @@ public class Comment extends BaseDomain { List reactions; + /** + * Indicates whether this comment is the leading comment in it's thread. Such a comment cannot be deleted. + */ + @JsonIgnore + Boolean leading; + @Data public static class Body { List blocks; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentNotification.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentNotification.java new file mode 100644 index 0000000000..b2277fd173 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentNotification.java @@ -0,0 +1,14 @@ +package com.appsmith.server.domains; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@EqualsAndHashCode(callSuper = true) +@Document +public class CommentNotification extends Notification { + + Comment comment; + +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThreadNotification.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThreadNotification.java new file mode 100644 index 0000000000..ab3834805c --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/CommentThreadNotification.java @@ -0,0 +1,14 @@ +package com.appsmith.server.domains; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@EqualsAndHashCode(callSuper = true) +@Document +public class CommentThreadNotification extends Notification { + + CommentThread commentThread; + +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java new file mode 100644 index 0000000000..79ffee5af1 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Notification.java @@ -0,0 +1,25 @@ +package com.appsmith.server.domains; + +import com.appsmith.external.models.BaseDomain; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@EqualsAndHashCode(callSuper = true) +@Document +public class Notification extends BaseDomain { + + // TODO: This class extends BaseDomain, so it has policies. Should we use information from policies instead of this field? + String forUsername; + + /** + * Read status for this notification. If it is `true`, then this notification is read. If `false` or `null`, it's unread. + */ + Boolean isRead; + + public String getType() { + return getClass().getSimpleName(); + } + +} 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 b67a6d967a..7c88e95432 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 @@ -13,11 +13,13 @@ import com.appsmith.server.repositories.ApplicationRepository; import com.appsmith.server.repositories.DatasourceRepository; import com.appsmith.server.repositories.NewActionRepository; import com.appsmith.server.repositories.NewPageRepository; +import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -282,4 +284,18 @@ public class PolicyUtils { return false; } + + public Set findUsernamesWithPermission(Set policies, AclPermission permission) { + if (CollectionUtils.isNotEmpty(policies) && permission != null) { + final String permissionString = permission.getValue(); + for (Policy policy : policies) { + if (permissionString.equals(policy.getPermission())) { + return policy.getUsers(); + } + } + } + + return Collections.emptySet(); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java new file mode 100644 index 0000000000..9f420f5cf7 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepository.java @@ -0,0 +1,6 @@ +package com.appsmith.server.repositories; + +import com.appsmith.server.domains.Notification; + +public interface CustomNotificationRepository extends AppsmithRepository { +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java new file mode 100644 index 0000000000..6c4b50c95c --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/CustomNotificationRepositoryImpl.java @@ -0,0 +1,13 @@ +package com.appsmith.server.repositories; + +import com.appsmith.server.domains.Notification; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.convert.MongoConverter; + +public class CustomNotificationRepositoryImpl extends BaseAppsmithRepositoryImpl implements CustomNotificationRepository { + + public CustomNotificationRepositoryImpl(ReactiveMongoOperations mongoOperations, MongoConverter mongoConverter) { + super(mongoOperations, mongoConverter); + } + +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java new file mode 100644 index 0000000000..eb81b59bb5 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/NotificationRepository.java @@ -0,0 +1,12 @@ +package com.appsmith.server.repositories; + +import com.appsmith.server.domains.Notification; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface NotificationRepository extends BaseRepository, CustomNotificationRepository { + + Flux findByForUsername(String userId); + +} 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 8d5678fee2..1935b125b0 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 @@ -6,7 +6,10 @@ import com.appsmith.server.acl.PolicyGenerator; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Comment; +import com.appsmith.server.domains.CommentNotification; 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.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; @@ -45,6 +48,7 @@ public class CommentServiceImpl extends BaseService create(String threadId, Comment comment) { + return create(threadId, comment, true); + } + + public Mono create(String threadId, Comment comment, 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(); @@ -112,7 +122,29 @@ public class CommentServiceImpl extends BaseService { + final User user = tuple.getT1(); + final Comment savedComment = tuple.getT2(); + + final Set usernames = policyUtils.findUsernamesWithPermission( + savedComment.getPolicies(), AclPermission.READ_COMMENT); + + List> monos = 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)); + } + } + + return Flux.concat(monos).then(Mono.just(savedComment)); }); } @@ -170,9 +202,12 @@ public class CommentServiceImpl extends BaseService> 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)); + commentSaverMonos.add(create(thread.getId(), comment, !isFirst)); + isFirst = false; } } @@ -181,10 +216,28 @@ public class CommentServiceImpl extends BaseService { + .zipWith(sessionUserService.getCurrentUser()) + .flatMap(tuple -> { + final List comments = tuple.getT1(); + final User user = tuple.getT2(); + commentThread.setComments(comments); commentThread.setIsViewed(true); - return commentThread; + + final Set usernames = policyUtils.findUsernamesWithPermission( + commentThread.getPolicies(), AclPermission.READ_THREAD); + + List> monos = new ArrayList<>(); + for (String username : usernames) { + if (!username.equals(user.getUsername())) { + final CommentThreadNotification notification = new CommentThreadNotification(); + notification.setCommentThread(commentThread); + notification.setForUsername(username); + monos.add(notificationService.create(notification)); + } + } + + return Flux.concat(monos).then(Mono.just(commentThread)); }); } 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 new file mode 100644 index 0000000000..cce58ac380 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationService.java @@ -0,0 +1,7 @@ +package com.appsmith.server.services; + +import com.appsmith.server.domains.Notification; + +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 new file mode 100644 index 0000000000..614e68fddc --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NotificationServiceImpl.java @@ -0,0 +1,61 @@ +package com.appsmith.server.services; + +import com.appsmith.server.domains.Notification; +import com.appsmith.server.repositories.NotificationRepository; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.stereotype.Service; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +import javax.validation.Validator; + +@Slf4j +@Service +public class NotificationServiceImpl + extends BaseService + implements NotificationService { + + private final SessionUserService sessionUserService; + + public NotificationServiceImpl( + Scheduler scheduler, + Validator validator, + MongoConverter mongoConverter, + ReactiveMongoTemplate reactiveMongoTemplate, + NotificationRepository repository, + AnalyticsService analyticsService, + SessionUserService sessionUserService + ) { + super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService); + this.sessionUserService = sessionUserService; + } + + @Override + public Mono create(Notification notification) { + Mono notificationWithUsernameMono; + if (StringUtils.isEmpty(notification.getForUsername())) { + notificationWithUsernameMono = sessionUserService.getCurrentUser() + .map(user -> { + notification.setForUsername(user.getUsername()); + return notification; + }); + } else { + notificationWithUsernameMono = Mono.just(notification); + } + + return notificationWithUsernameMono + .flatMap(super::create); + } + + @Override + public Flux get(MultiValueMap params) { + return sessionUserService.getCurrentUser() + .flatMapMany(user -> repository.findByForUsername(user.getUsername())); + } + +}