doortts doortts 2018-01-31
issue: Issue sharing feature - support notification
@679c6c0c5fd26078b0e19a75fbf67d49abcbaee1
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -3066,6 +3066,14 @@
                 &.rejected {background: #F39C12;}
                 &.conflict {background: #C0392B;}
                 &.resolved {background: #468847;}
+                &.sharer-added {
+                    background-color: #B2EBF2;
+                    color: #0097A7;
+                }
+                &.sharer-deleted {
+                    background-color: #FFCCBC;
+                    color: #F4511E;
+                }
              }
 
              em { font-size:12px; color:#7F8C8D; margin-right:5px;}
app/controllers/api/IssueApi.java
--- app/controllers/api/IssueApi.java
+++ app/controllers/api/IssueApi.java
@@ -39,6 +39,7 @@
 import java.util.*;
 
 import static controllers.UserApp.MAX_FETCH_USERS;
+import static controllers.UserApp.currentUser;
 import static controllers.api.UserApi.createUserNode;
 import static play.libs.Json.toJson;
 
@@ -602,10 +603,6 @@
             return badRequest(Json.newObject().put("message", "Expecting Json data"));
         }
 
-        if(noSharer(json.findValue("sharer"))){
-            return badRequest(Json.newObject().put("message", "No sharer"));
-        }
-
         Project project = Project.findByOwnerAndProjectName(owner, projectName);
         Issue issue = Issue.findByNumber(project, number);
         if (!AccessControl.isAllowed(UserApp.currentUser(), issue.asResource(),
@@ -613,26 +610,53 @@
             return forbidden(Json.newObject().put("message", "Permission denied"));
         }
 
-        ObjectNode result = Json.newObject();
-        String action = json.findValue("action").asText();
+        JsonNode sharer = json.findValue("sharer");
+        if(noSharer(sharer)){
+            return badRequest(Json.newObject().put("message", "No sharer"));
+        }
 
-        for(JsonNode sharerLoginId: json.findValue("sharer")){
-            switch (action) {
-                case "delete":
-                    removeSharer(issue, sharerLoginId.asText());
-                    result.put("action", "deleted");
-                    break;
-                case "add":
-                    addSharer(issue, sharerLoginId.asText());
-                    result.put("action", "added");
-                    break;
-                default:
-                    result.put("action", "Do nothing");
+        final String action = json.findValue("action").asText();
+
+        ObjectNode result = changeSharer(sharer, issue, action);
+        sendNotification(sharer, issue, action);
+
+        return ok(result);
+    }
+
+    private static ObjectNode changeSharer(JsonNode sharer, Issue issue, String action) {
+        ObjectNode result = Json.newObject();
+        for (JsonNode sharerLoginId : sharer) {
+            if ("add".equalsIgnoreCase(action)) {
+                addSharer(issue, sharerLoginId.asText());
+                result.put("action", "added");
+            } else if ("delete".equalsIgnoreCase(action)) {
+                result.put("action", "deleted");
+                removeSharer(issue, sharerLoginId.asText());
+            } else {
+                play.Logger.error("Unknown issue sharing action: " + issue + ":" + action + " by " + currentUser());
+                result.put("action", "Do nothing. Unsupported action: " + action);
             }
             result.put("sharer", User.findByLoginId(sharerLoginId.asText()).getDisplayName());
         }
+        return result;
+    }
 
-        return ok(result);
+    private static void sendNotification(JsonNode sharer, Issue issue, String action) {
+        Runnable preUpdateHook = new Runnable() {
+            @Override
+            public void run() {
+                for(JsonNode sharerLoginId: sharer){
+                    addSharerChangedNotification(issue, sharerLoginId.asText(), action);
+                }
+            }
+        };
+        preUpdateHook.run();
+    }
+
+
+    private static void addSharerChangedNotification(Issue issue, String sharerLoginId, String action) {
+        NotificationEvent notiEvent = NotificationEvent.afterIssueSharerChanged(issue, sharerLoginId, action);
+        IssueEvent.addFromNotificationEventWithoutSkipEvent(notiEvent, issue, UserApp.currentUser().loginId);
     }
 
     private static boolean noSharer(JsonNode sharers) {
app/models/IssueEvent.java
--- app/models/IssueEvent.java
+++ app/models/IssueEvent.java
@@ -1,23 +1,10 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2013 NAVER Corp.
- * http://yobi.io
- *
- * @author Yi EungJun
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
+
 package models;
 
 import models.enumeration.EventType;
@@ -91,8 +78,7 @@
                 .orderBy("id desc").setMaxRows(1).findUnique();
 
         if (lastEvent != null) {
-            if (lastEvent.eventType == event.eventType &&
-                    StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId)) {
+            if (isSameUserEventAsPrevious(event, lastEvent)) {
                 // A -> B, B -> C ==> A -> C
                 event.oldValue = lastEvent.oldValue;
                 lastEvent.delete();
@@ -107,6 +93,45 @@
         }
 
         event.save();
+    }
+
+
+    /**
+     * It is nearly same as {@link #add(IssueEvent)} except that it doesn't skip waypoint events.
+     *
+     * For example, if events of an issue are occurred continuously
+     * A -> B -> C, then {@link #add(IssueEvent)} method skip B.
+     *
+     * This method doesn't skip B and leave it.
+     *
+     * @param event
+     */
+    public static void addWithoutSkipEvent(IssueEvent event) {
+        Date draftDate = DateTime.now().minusMillis(DRAFT_TIME_IN_MILLIS).toDate();
+
+        IssueEvent lastEvent = IssueEvent.find.where()
+                .eq("issue.id", event.issue.id)
+                .gt("created", draftDate)
+                .orderBy("id desc").setMaxRows(1).findUnique();
+
+        if (lastEvent != null) {
+            if (isSameUserEventAsPrevious(event, lastEvent) &&
+                    isRevertingTheValue(event, lastEvent)) {
+                lastEvent.delete();
+                return;
+            }
+        }
+        event.save();
+    }
+
+    private static boolean isRevertingTheValue(IssueEvent event, IssueEvent lastEvent) {
+        return StringUtils.equals(event.oldValue, lastEvent.newValue) &&
+                StringUtils.equals(event.newValue, lastEvent.oldValue);
+    }
+
+    private static boolean isSameUserEventAsPrevious(IssueEvent event, IssueEvent lastEvent) {
+        return lastEvent.eventType == event.eventType &&
+                StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId);
     }
 
     /**
@@ -130,6 +155,18 @@
         add(event);
     }
 
+    public static void addFromNotificationEventWithoutSkipEvent(NotificationEvent notiEvent, Issue updatedIssue,
+                                                String senderLoginId) {
+        IssueEvent event = new IssueEvent();
+        event.created = notiEvent.created;
+        event.senderLoginId = senderLoginId;
+        event.issue = updatedIssue;
+        event.eventType = notiEvent.eventType;
+        event.oldValue = notiEvent.oldValue;
+        event.newValue = notiEvent.newValue;
+        addWithoutSkipEvent(event);
+    }
+
     @Override
     public Date getDate() {
         return created;
app/models/IssueSharer.java
--- app/models/IssueSharer.java
+++ app/models/IssueSharer.java
@@ -29,6 +29,9 @@
     @OneToOne
     public Issue issue;
 
+    public static final String ADD = "add";
+    public static final String DELETE = "delete";
+
     public static final Finder<Long, IssueSharer> find = new Finder<>(Long.class,
             IssueSharer.class);
 
@@ -40,7 +43,7 @@
         issueSharer.user = User.findByLoginId(loginId);
 
         if (issueSharer.user == null) {
-            String errorMsg  = "Wrong loginId for issue sharing: " + loginId;
+            String errorMsg = "Wrong loginId for issue sharing: " + loginId;
             play.Logger.error(errorMsg);
             throw new IllegalArgumentException(errorMsg);
         }
app/models/NotificationEvent.java
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.java
@@ -1,7 +1,7 @@
 /**
  * Yona, 21st Century Project Hosting SW
  * <p>
- * Copyright Yona & Yobi Authors & NAVER Corp.
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
  * https://yona.io
  **/
 package models;
@@ -14,7 +14,6 @@
 import models.resource.GlobalResource;
 import models.resource.Resource;
 import models.resource.ResourceConvertible;
-import models.Webhook;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.collections.Predicate;
 import org.apache.commons.lang3.StringUtils;
@@ -108,6 +107,10 @@
         return oldValue;
     }
 
+    public String getNewValue() {
+        return newValue;
+    }
+
     @Transient
     public String getMessage() {
         return getMessage(Lang.defaultLang());
@@ -189,7 +192,14 @@
                 }
             case ISSUE_MOVED:
                     return Messages.get(lang, "notification.type.issue.moved", oldValue, newValue);
+            case ISSUE_SHARER_CHANGED:
+                if (StringUtils.isNotBlank(newValue)) {
+                    return Messages.get(lang, "notification.issue.sharer.added", User.findByLoginId(newValue).getDisplayName());
+                } else if (StringUtils.isNotBlank(oldValue)) {
+                    return Messages.get(lang, "notification.issue.sharer.deleted");
+                }
             default:
+                play.Logger.error("Unknown event message: " + this);
                 return null;
         }
     }
@@ -360,8 +370,7 @@
                 .orderBy("id desc").setMaxRows(1).findUnique();
 
         if (lastEvent != null) {
-            if (lastEvent.eventType == event.eventType &&
-                    event.senderId.equals(lastEvent.senderId)) {
+            if (isSameUserEventAsPrevious(event, lastEvent)) {
                 // If the last event is A -> B and the current event is B -> C,
                 // they are merged into the new event A -> C.
                 event.oldValue = lastEvent.getOldValue();
@@ -381,6 +390,55 @@
         }
         event.save();
         event.saveManyToManyAssociations("receivers");
+    }
+
+    public static void addWithoutSkipEvent(NotificationEvent event) {
+        if (event.notificationMail == null) {
+            event.notificationMail = new NotificationMail();
+            event.notificationMail.notificationEvent = event;
+        }
+
+        Date draftDate = DateTime.now().minusMillis(EventConstants.DRAFT_TIME_IN_MILLIS).toDate();
+
+        NotificationEvent lastEvent = NotificationEvent.find.where()
+                .eq("resourceId", event.resourceId)
+                .eq("resourceType", event.resourceType)
+                .gt("created", draftDate)
+                .orderBy("id desc").setMaxRows(1).findUnique();
+
+        if (lastEvent != null) {
+            if (isSameUserEventAsPrevious(event, lastEvent) &&
+                    isRevertingTheValue(event, lastEvent)) {
+                lastEvent.delete();
+                return;
+            }
+        }
+
+        if(isAddingSharerEvent(event)){
+            filterReceivers(event);
+        }
+
+        if (event.receivers.isEmpty()) {
+            return;
+        }
+        event.save();
+        event.saveManyToManyAssociations("receivers");
+    }
+
+    private static boolean isSameUserEventAsPrevious(NotificationEvent event, NotificationEvent lastEvent) {
+        return lastEvent.eventType == event.eventType &&
+                event.senderId.equals(lastEvent.senderId);
+    }
+
+    private static boolean isRevertingTheValue(NotificationEvent event, NotificationEvent lastEvent) {
+        return StringUtils.equals(event.oldValue, lastEvent.newValue) &&
+                StringUtils.equals(event.newValue, lastEvent.oldValue);
+    }
+
+    private static boolean isAddingSharerEvent(NotificationEvent event) {
+        return event.eventType.equals(EventType.ISSUE_SHARER_CHANGED)
+            && StringUtils.isBlank(event.oldValue)
+            && StringUtils.isNotBlank(event.newValue);
     }
 
     private static void filterReceivers(final NotificationEvent event) {
@@ -791,6 +849,30 @@
         NotificationEvent.add(notiEvent);
 
         return notiEvent;
+    }
+
+    public static NotificationEvent afterIssueSharerChanged(Issue issue, String sharerLoginId, String action) {
+        NotificationEvent notiEvent = createFromCurrentUser(issue);
+        notiEvent.title = formatReplyTitle(issue);
+        notiEvent.receivers = findSharer(sharerLoginId);
+        notiEvent.eventType = ISSUE_SHARER_CHANGED;
+        if (IssueSharer.ADD.equalsIgnoreCase(action)) {
+            notiEvent.oldValue = "";
+            notiEvent.newValue = sharerLoginId;
+        } else if (IssueSharer.DELETE.equalsIgnoreCase(action)) {
+            notiEvent.oldValue = sharerLoginId;
+            notiEvent.newValue = "";
+        }
+
+        NotificationEvent.addWithoutSkipEvent(notiEvent);
+
+        return notiEvent;
+    }
+
+    private static Set<User> findSharer(String sharerLoginId) {
+        Set<User> receivers = new HashSet<>();
+        receivers.add(User.findByLoginId(sharerLoginId));
+        return receivers;
     }
 
     private static Set<User> getReceiversForIssueBodyChanged(String oldBody, Issue issue) {
@@ -1266,4 +1348,21 @@
         webhookRequest(COMMENT_UPDATED, comment, false);
         NotificationEvent.add(forUpdatedComment(comment, UserApp.currentUser()));
     }
+
+    @Override
+    public String toString() {
+        return "NotificationEvent{" +
+                "id=" + id +
+                ", title='" + title + '\'' +
+                ", senderId=" + senderId +
+                ", receivers=" + receivers +
+                ", created=" + created +
+                ", resourceType=" + resourceType +
+                ", resourceId='" + resourceId + '\'' +
+                ", eventType=" + eventType +
+                ", oldValue='" + oldValue + '\'' +
+                ", newValue='" + newValue + '\'' +
+                ", notificationMail=" + notificationMail +
+                '}';
+    }
 }
app/models/enumeration/EventType.java
--- app/models/enumeration/EventType.java
+++ app/models/enumeration/EventType.java
@@ -1,7 +1,7 @@
 /**
  * Yona, 21st Century Project Hosting SW
  * <p>
- * Copyright Yona & Yobi Authors & NAVER Corp.
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
  * https://yona.io
  **/
 package models.enumeration;
@@ -34,7 +34,8 @@
     REVIEW_THREAD_STATE_CHANGED("notification.type.review.state.changed", 18),
     ORGANIZATION_MEMBER_ENROLL_REQUEST("notification.organization.type.member.enroll",19),
     COMMENT_UPDATED("notification.type.comment.updated", 20),
-    ISSUE_MOVED("notification.type.issue.is.moved", 21);
+    ISSUE_MOVED("notification.type.issue.is.moved", 21),
+    ISSUE_SHARER_CHANGED("notification.type.issue.sharer.changed", 22);
 
     private String descr;
 
app/views/issue/partial_comments.scala.html
--- app/views/issue/partial_comments.scala.html
+++ app/views/issue/partial_comments.scala.html
@@ -1,7 +1,7 @@
 @**
 * Yona, 21st Century Project Hosting SW
 *
-* Copyright Yona & Yobi Authors & NAVER Corp.
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
 * https://yona.io
 **@
 @(project:Project, issue:Issue)
@@ -178,6 +178,15 @@
                                 @Html(Messages("issue.event.referred",linkToUser(user.loginId, user.getDisplayName),linkToPullRequest(pull)))
                             }
                         }
+                        case EventType.ISSUE_SHARER_CHANGED => {
+                            @if(StringUtils.isBlank(event.oldValue) && StringUtils.isNotBlank(event.newValue)){
+                                <span class="state sharer-added">@Messages("issue.sharer")</span>
+                                @Html(Messages("issue.event.sharer.added", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.newValue, User.findByLoginId(event.newValue).getPureNameOnly)))
+                            } else {
+                                <span class="state sharer-deleted">@Messages("issue.event.sharer.deleted.title")</span>
+                                @Html(Messages("issue.event.sharer.deleted", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.oldValue, User.findByLoginId(event.oldValue).getPureNameOnly)))
+                            }
+                        }
                         case _ => {
                             @event.newValue by @linkToUser(user.loginId, user.getDisplayName)
                         }
conf/messages
--- conf/messages
+++ conf/messages
@@ -284,6 +284,10 @@
 issue.event.open = {0} reopened this issue
 issue.event.referred = {0} mentioned this issue in {1}
 issue.event.referred.title = mentioned
+issue.event.sharer.added = {0} shared current issue to {1}
+issue.event.sharer.added.title = Issue sharing
+issue.event.sharer.deleted = {0} cancelled issue sharing with {1}
+issue.event.sharer.deleted.title = Cancelled
 issue.event.unassigned = {0} set assignee to unassigned
 issue.is.empty = No issue found
 issue.label = Issue Label
@@ -405,6 +409,8 @@
 notification.issue.closed  = Issue has been closed
 notification.issue.reopened = Issue has been reopened
 notification.issue.unassigned = Issue has been unassigned
+notification.issue.sharer.added = Issue is shared with {0}
+notification.issue.sharer.deleted = Issue sharing state is changed
 notification.linkToView = View it on {0}
 notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a>
 notification.member.enroll.accept = Accepted as a member.
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -284,6 +284,10 @@
 issue.event.open = {0}님이 이 이슈를 다시 열었습니다.
 issue.event.referred = {0} 님이 {1} 에서 이 이슈를 언급했습니다.
 issue.event.referred.title = 언급됨
+issue.event.sharer.added = {0} 님이 현재 이슈를 {1}님에게 공유했습니다.
+issue.event.sharer.added.title = 이슈 공유
+issue.event.sharer.deleted = {0} 님이 {1}님을 공유대상에서 제외했습니다.
+issue.event.sharer.deleted.title = 공유 취소
 issue.event.unassigned = {0}님이 이 이슈의 담당자를 "없음"으로 설정하였습니다.
 issue.is.empty = 등록된 이슈가 없습니다.
 issue.label = 이슈 라벨
@@ -404,6 +408,8 @@
 notification.issue.assigned = {0}에게 이슈 할당됨
 notification.issue.closed  = 이슈 닫힘
 notification.issue.reopened = 이슈 다시 열림
+notification.issue.sharer.added = {0} 님에게 이슈가 공유되었습니다.
+notification.issue.sharer.deleted = 이슈 공유 상태가 변경되었습니다.
 notification.issue.unassigned = 이슈 담당자 없음
 notification.linkToView = {0}에서 보기
 notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a>
@@ -446,6 +452,7 @@
 notification.type.issue.moved = 이슈가 {0}에서 {1}로 이동되었습니다
 notification.type.issue.referred.from.commit = 커밋에서의 이슈 언급
 notification.type.issue.referred.from.pullrequest = 코드 주고받기에서의 이슈 언급
+notification.type.issue.sharer.changed = 이슈 공유 변경
 notification.type.issue.state.changed = 이슈 상태 변경
 notification.type.member.enroll = 멤버 등록 요청
 notification.type.new.comment = 새 댓글 등록
Add a comment
List