Merge pull request #4571 from appsmithorg/feature/notifications-api

Basics and infrastructure for a Notifications system
This commit is contained in:
Shrikant Sharat Kandula 2021-05-20 13:49:56 +05:30 committed by GitHub
commit 94d4302bcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 281 additions and 4 deletions

View File

@ -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))

View File

@ -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";
}

View File

@ -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<NotificationService, Notification, String> {
@Autowired
public NotificationController(NotificationService service) {
super(service);
}
}

View File

@ -36,6 +36,12 @@ public class Comment extends BaseDomain {
List<Reaction> 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<Block> blocks;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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<String> findUsernamesWithPermission(Set<Policy> 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();
}
}

View File

@ -0,0 +1,6 @@
package com.appsmith.server.repositories;
import com.appsmith.server.domains.Notification;
public interface CustomNotificationRepository extends AppsmithRepository<Notification> {
}

View File

@ -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<Notification> implements CustomNotificationRepository {
public CustomNotificationRepositoryImpl(ReactiveMongoOperations mongoOperations, MongoConverter mongoConverter) {
super(mongoOperations, mongoConverter);
}
}

View File

@ -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<Notification, String>, CustomNotificationRepository {
Flux<Notification> findByForUsername(String userId);
}

View File

@ -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<CommentRepository, Comment,
private final UserService userService;
private final SessionUserService sessionUserService;
private final ApplicationService applicationService;
private final NotificationService notificationService;
private final PolicyGenerator policyGenerator;
private final PolicyUtils policyUtils;
@ -60,6 +64,7 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
UserService userService,
SessionUserService sessionUserService,
ApplicationService applicationService,
NotificationService notificationService,
PolicyGenerator policyGenerator,
PolicyUtils policyUtils
) {
@ -68,12 +73,17 @@ public class CommentServiceImpl extends BaseService<CommentRepository, Comment,
this.userService = userService;
this.sessionUserService = sessionUserService;
this.applicationService = applicationService;
this.notificationService = notificationService;
this.policyGenerator = policyGenerator;
this.policyUtils = policyUtils;
}
@Override
public Mono<Comment> create(String threadId, Comment comment) {
return create(threadId, comment, true);
}
public Mono<Comment> 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<CommentRepository, Comment,
String authorName = user.getName() != null ? user.getName(): user.getUsername();
comment.setAuthorName(authorName);
return repository.save(comment);
return Mono.zip(
Mono.just(user),
repository.save(comment)
);
})
.flatMap(tuple -> {
final User user = tuple.getT1();
final Comment savedComment = tuple.getT2();
final Set<String> usernames = policyUtils.findUsernamesWithPermission(
savedComment.getPolicies(), AclPermission.READ_COMMENT);
List<Mono<Notification>> 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<CommentRepository, Comment,
List<Mono<Comment>> 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<CommentRepository, Comment,
return Flux.concat(commentSaverMonos);
})
.collectList()
.map(comments -> {
.zipWith(sessionUserService.getCurrentUser())
.flatMap(tuple -> {
final List<Comment> comments = tuple.getT1();
final User user = tuple.getT2();
commentThread.setComments(comments);
commentThread.setIsViewed(true);
return commentThread;
final Set<String> usernames = policyUtils.findUsernamesWithPermission(
commentThread.getPolicies(), AclPermission.READ_THREAD);
List<Mono<Notification>> 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));
});
}

View File

@ -0,0 +1,7 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Notification;
public interface NotificationService extends CrudService<Notification, String> {
}

View File

@ -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<NotificationRepository, Notification, String>
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<Notification> create(Notification notification) {
Mono<Notification> 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<Notification> get(MultiValueMap<String, String> params) {
return sessionUserService.getCurrentUser()
.flatMapMany(user -> repository.findByForUsername(user.getUsername()));
}
}