Added expiration time check for member invitation.

Change-Id: Id119811e7cae6e9418d29618bd89e4f6812bc95a
diff --git a/full/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java b/full/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
index 9803461..5aa06d8 100644
--- a/full/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
+++ b/full/src/main/java/de/ids_mannheim/korap/dao/UserGroupMemberDao.java
@@ -36,45 +36,34 @@
         entityManager.persist(member);
     }
 
-    public void addMembers (List<UserGroupMember> members)
-            throws KustvaktException {
-        ParameterChecker.checkObjectValue(members, "List<UserGroupMember>");
+//    @Deprecated
+//    public void addMembers (List<UserGroupMember> members)
+//            throws KustvaktException {
+//        ParameterChecker.checkObjectValue(members, "List<UserGroupMember>");
+//
+//        for (UserGroupMember member : members) {
+//            addMember(member);
+//        }
+//    }
 
-        for (UserGroupMember member : members) {
-            addMember(member);
-        }
+    public void updateMember (UserGroupMember member)
+            throws KustvaktException {
+        ParameterChecker.checkObjectValue(member, "UserGroupMember");
+        entityManager.merge(member);
     }
 
-    public void approveMember (String userId, int groupId)
-            throws KustvaktException {
-        ParameterChecker.checkStringValue(userId, "userId");
-        ParameterChecker.checkIntegerValue(groupId, "groupId");
-
-        UserGroupMember member = retrieveMemberById(userId, groupId);
-        if (member.getStatus().equals(GroupMemberStatus.DELETED)) {
-            throw new KustvaktException(StatusCodes.NOTHING_CHANGED, "Username "
-                    + userId + " had been deleted in group " + groupId, userId);
-        }
-
-        member.setStatus(GroupMemberStatus.ACTIVE);
-        entityManager.persist(member);
-    }
-
-    public void deleteMember (String userId, int groupId, String deletedBy,
+    public void deleteMember (UserGroupMember member, String deletedBy,
             boolean isSoftDelete) throws KustvaktException {
-        ParameterChecker.checkStringValue(userId, "userId");
-        ParameterChecker.checkIntegerValue(groupId, "groupId");
+        ParameterChecker.checkObjectValue(member, "UserGroupMember");
+        ParameterChecker.checkStringValue(deletedBy, "deletedBy");
 
-        UserGroupMember member = retrieveMemberById(userId, groupId);
-        GroupMemberStatus status = member.getStatus();
-        if (isSoftDelete && status.equals(GroupMemberStatus.DELETED)) {
-            throw new KustvaktException(StatusCodes.DB_ENTRY_DELETED,
-                    userId + " has already been deleted from the group.",
-                    userId);
+        if (!entityManager.contains(member)) {
+            member = entityManager.merge(member);
         }
 
         if (isSoftDelete) {
             member.setStatus(GroupMemberStatus.DELETED);
+            member.setDeletedBy(deletedBy);
             entityManager.persist(member);
         }
         else {
diff --git a/full/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java b/full/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
index 6e7f874..f148959 100644
--- a/full/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
+++ b/full/src/main/java/de/ids_mannheim/korap/entity/UserGroupMember.java
@@ -1,5 +1,7 @@
 package de.ids_mannheim.korap.entity;
 
+import java.time.ZonedDateTime;
+import java.util.Date;
 import java.util.List;
 
 import javax.persistence.Column;
@@ -47,6 +49,10 @@
     private String createdBy;
     @Column(name = "deleted_by")
     private String deletedBy;
+    
+    // auto update in the database
+    @Column(name = "status_date")
+    private ZonedDateTime statusDate;
 
     @Enumerated(EnumType.STRING)
     private GroupMemberStatus status;
diff --git a/full/src/main/java/de/ids_mannheim/korap/service/UserGroupService.java b/full/src/main/java/de/ids_mannheim/korap/service/UserGroupService.java
index 15f6052..d39eb89 100644
--- a/full/src/main/java/de/ids_mannheim/korap/service/UserGroupService.java
+++ b/full/src/main/java/de/ids_mannheim/korap/service/UserGroupService.java
@@ -1,9 +1,15 @@
 package de.ids_mannheim.korap.service;
 
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -37,6 +43,8 @@
 @Service
 public class UserGroupService {
 
+    private static Logger jlog =
+            LoggerFactory.getLogger(UserGroupService.class);
     @Autowired
     private UserGroupDao userGroupDao;
     @Autowired
@@ -199,7 +207,8 @@
     public void deleteAutoHiddenGroup (int groupId, String deletedBy)
             throws KustvaktException {
         // default hard delete
-        userGroupDao.deleteGroup(groupId, deletedBy, config.isSoftDeleteAutoGroup());
+        userGroupDao.deleteGroup(groupId, deletedBy,
+                config.isSoftDeleteAutoGroup());
     }
 
     /** Adds a user to the specified usergroup. If the username with 
@@ -225,9 +234,10 @@
         ParameterChecker.checkIntegerValue(groupId, "userGroupId");
 
         if (memberExists(username, groupId, status)) {
-            throw new KustvaktException(StatusCodes.DB_ENTRY_EXISTS,
+            throw new KustvaktException(StatusCodes.GROUP_MEMBER_EXISTS,
                     "Username " + username + " with status " + status
-                            + " exists in user-group " + userGroup.getName(),
+                            + " exists in the user-group "
+                            + userGroup.getName(),
                     username, status.name(), userGroup.getName());
         }
 
@@ -261,7 +271,7 @@
         }
         else if (existingStatus.equals(GroupMemberStatus.DELETED)) {
             // hard delete, not customizable
-            groupMemberDao.deleteMember(username, groupId, "system", false);
+            deleteMember(username, groupId, "system", false);
         }
 
         return false;
@@ -307,26 +317,47 @@
      * @param username the username of the group member
      * @throws KustvaktException
      */
-    public void subscribe (int groupId, String username)
+    public void acceptInvitation (int groupId, String username)
             throws KustvaktException {
-        groupMemberDao.approveMember(username, groupId);
+
+        ParameterChecker.checkStringValue(username, "userId");
+        ParameterChecker.checkIntegerValue(groupId, "groupId");
+
+        UserGroupMember member =
+                groupMemberDao.retrieveMemberById(username, groupId);
+        GroupMemberStatus status = member.getStatus();
+        if (status.equals(GroupMemberStatus.DELETED)) {
+            UserGroup group = userGroupDao.retrieveGroupById(groupId);
+            throw new KustvaktException(StatusCodes.GROUP_MEMBER_DELETED,
+                    username + " has already been deleted from the group "
+                            + group.getName(),
+                    username, group.getName());
+        }
+        else if (member.getStatus().equals(GroupMemberStatus.ACTIVE)) {
+            UserGroup userGroup = retrieveUserGroupById(groupId);
+            throw new KustvaktException(StatusCodes.GROUP_MEMBER_EXISTS,
+                    "Username " + username + " with status " + status
+                            + " exists in the user-group "
+                            + userGroup.getName(),
+                    username, status.name(), userGroup.getName());
+        }
+        // status pending
+        else {
+            jlog.debug("status: " +member.getStatusDate());
+            ZonedDateTime expiration = member.getStatusDate().plusMinutes(30);
+            ZonedDateTime now = ZonedDateTime.now();
+            jlog.debug("expiration: " + expiration + ", now: " + now);
+
+            if (expiration.isAfter(now)){
+                member.setStatus(GroupMemberStatus.ACTIVE);
+                groupMemberDao.updateMember(member);
+            }
+            else{
+                throw new KustvaktException(StatusCodes.INVITATION_EXPIRED);
+            }
+        }
     }
 
-
-    /** Updates the {@link GroupMemberStatus} of a member to 
-     * {@link GroupMemberStatus#DELETED}
-     * 
-     * @param groupId groupId
-     * @param username member's username
-     * @throws KustvaktException
-     */
-    public void unsubscribe (int groupId, String username)
-            throws KustvaktException {
-        groupMemberDao.deleteMember(username, groupId, username,
-                config.isSoftDeleteGroupMember());
-    }
-
-
     public boolean isMember (String username, UserGroup userGroup)
             throws KustvaktException {
         List<UserGroupMember> members =
@@ -349,10 +380,11 @@
                     "Operation " + "'delete group owner'" + "is not allowed.",
                     "delete group owner");
         }
-        else if (isUserGroupAdmin(deletedBy, userGroup)
+        else if (memberId.equals(deletedBy)
+                || isUserGroupAdmin(deletedBy, userGroup)
                 || user.isSystemAdmin()) {
             // soft delete
-            groupMemberDao.deleteMember(memberId, groupId, deletedBy,
+            deleteMember(memberId, groupId, deletedBy,
                     config.isSoftDeleteGroupMember());
         }
         else {
@@ -361,4 +393,29 @@
         }
     }
 
+    /** Updates the {@link GroupMemberStatus} of a member to 
+     * {@link GroupMemberStatus#DELETED}
+     * 
+     * @param userId user to be deleted
+     * @param groupId user-group id
+     * @param deletedBy user that issue the delete 
+     * @param isSoftDelete true if database entry is to be deleted 
+     * permanently, false otherwise
+     * @throws KustvaktException
+     */
+    private void deleteMember (String username, int groupId, String deletedBy,
+            boolean isSoftDelete) throws KustvaktException {
+        UserGroupMember member =
+                groupMemberDao.retrieveMemberById(username, groupId);
+        GroupMemberStatus status = member.getStatus();
+        if (isSoftDelete && status.equals(GroupMemberStatus.DELETED)) {
+            UserGroup group = userGroupDao.retrieveGroupById(groupId);
+            throw new KustvaktException(StatusCodes.GROUP_MEMBER_DELETED,
+                    username + " has already been deleted from the group "
+                            + group.getName(),
+                    username, group.getName());
+        }
+
+        groupMemberDao.deleteMember(member, deletedBy, isSoftDelete);
+    }
 }
diff --git a/full/src/main/java/de/ids_mannheim/korap/web/controller/UserGroupController.java b/full/src/main/java/de/ids_mannheim/korap/web/controller/UserGroupController.java
index ad92c84..eda4eeb 100644
--- a/full/src/main/java/de/ids_mannheim/korap/web/controller/UserGroupController.java
+++ b/full/src/main/java/de/ids_mannheim/korap/web/controller/UserGroupController.java
@@ -59,6 +59,11 @@
     @Autowired
     private UserGroupService service;
 
+    /** Returns all user-groups wherein a user is an active or pending member.
+     * 
+     * @param securityContext
+     * @return a list of user-groups
+     */
     @GET
     @Path("list")
     public Response getUserGroup (@Context SecurityContext securityContext) {
@@ -120,8 +125,7 @@
     @DELETE
     @Path("delete")
     @Consumes(MediaType.APPLICATION_JSON)
-    public Response deleteUserGroup (
-            @Context SecurityContext securityContext,
+    public Response deleteUserGroup (@Context SecurityContext securityContext,
             @QueryParam("groupId") int groupId) {
         TokenContext context =
                 (TokenContext) securityContext.getUserPrincipal();
@@ -133,7 +137,7 @@
             throw responseHandler.throwit(e);
         }
     }
-    
+
     /** Group owner cannot be deleted.
      * 
      * @param securityContext
@@ -183,7 +187,7 @@
         TokenContext context =
                 (TokenContext) securityContext.getUserPrincipal();
         try {
-            service.subscribe(groupId, context.getUsername());
+            service.acceptInvitation(groupId, context.getUsername());
             return Response.ok().build();
         }
         catch (KustvaktException e) {
@@ -200,7 +204,8 @@
         TokenContext context =
                 (TokenContext) securityContext.getUserPrincipal();
         try {
-            service.unsubscribe(groupId, context.getUsername());
+            service.deleteGroupMember(context.getUsername(), groupId,
+                    context.getUsername());
             return Response.ok().build();
         }
         catch (KustvaktException e) {
diff --git a/full/src/main/resources/db/new-mysql/V1.1__create_virtual_corpus_tables.sql b/full/src/main/resources/db/new-mysql/V1.1__create_virtual_corpus_tables.sql
index a8df972..1fe0f26 100644
--- a/full/src/main/resources/db/new-mysql/V1.1__create_virtual_corpus_tables.sql
+++ b/full/src/main/resources/db/new-mysql/V1.1__create_virtual_corpus_tables.sql
@@ -32,6 +32,7 @@
   status varchar(100) NOT NULL,
   created_by varchar(100) NOT NULL,
   deleted_by varchar(100) DEFAULT NULL,
+  status_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   UNIQUE INDEX unique_index (user_id,group_id),
   INDEX status_index(status),
   FOREIGN KEY (group_id) 
diff --git a/full/src/main/resources/db/new-sqlite/V1.1__create_virtual_corpus_tables.sql b/full/src/main/resources/db/new-sqlite/V1.1__create_virtual_corpus_tables.sql
index 8476535..d1bbcb7 100644
--- a/full/src/main/resources/db/new-sqlite/V1.1__create_virtual_corpus_tables.sql
+++ b/full/src/main/resources/db/new-sqlite/V1.1__create_virtual_corpus_tables.sql
@@ -35,6 +35,8 @@
   status varchar(100) NOT NULL,
   created_by varchar(100) NOT NULL,
   deleted_by varchar(100) DEFAULT NULL,
+-- interprets now as localtime and save it as UTC
+  status_date timestamp DEFAULT (datetime('now','localtime')),
   FOREIGN KEY (group_id) 
   	REFERENCES user_group (id)
   	ON DELETE CASCADE
diff --git a/full/src/main/resources/db/new-sqlite/V1.2__triggers.sql b/full/src/main/resources/db/new-sqlite/V1.2__triggers.sql
new file mode 100644
index 0000000..0acd2f7
--- /dev/null
+++ b/full/src/main/resources/db/new-sqlite/V1.2__triggers.sql
@@ -0,0 +1,9 @@
+--CREATE TRIGGER insert_member_status AFTER INSERT ON user_group_member
+--     BEGIN
+--      UPDATE user_group_member SET status_date = DATETIME('NOW', 'utc')  WHERE rowid = new.rowid;
+--     END;
+--
+CREATE TRIGGER update_member_status AFTER UPDATE ON user_group_member	
+     BEGIN
+      UPDATE user_group_member SET status_date = (datetime('now','localtime'))  WHERE rowid = old.rowid;
+     END;   
diff --git a/full/src/main/resources/default-config.xml b/full/src/main/resources/default-config.xml
index 625460f..cd52fc0 100644
--- a/full/src/main/resources/default-config.xml
+++ b/full/src/main/resources/default-config.xml
@@ -87,6 +87,12 @@
 		<property name="url" value="${jdbc.url}" />
 		<property name="username" value="${jdbc.username}" />
 		<property name="password" value="${jdbc.password}" />
+		<property name="connectionProperties">
+            <props>
+                <prop key="date_string_format">yyyy-MM-dd HH:mm:ss</prop>
+            </props>
+        </property>
+        
 		<!-- relevant for single connection datasource and sqlite -->
 		<property name="suppressClose">
 			<value>true</value>
diff --git a/full/src/main/resources/jdbc.properties b/full/src/main/resources/jdbc.properties
index fb06a12..2bc85dd 100644
--- a/full/src/main/resources/jdbc.properties
+++ b/full/src/main/resources/jdbc.properties
@@ -4,7 +4,7 @@
 
 #jdbc.database=mysql
 #jdbc.driverClassName=com.mysql.jdbc.Driver
-#jdbc.url=jdbc:mysql://localhost:3306/kustvakt?autoReconnect=true
+#jdbc.url=jdbc:mysql://localhost:3306/kustvakt?autoReconnect=true&useLegacyDatetimeCode=false
 #jdbc.username=korap
 #jdbc.password=password
 
diff --git a/full/src/main/resources/log4j.properties b/full/src/main/resources/log4j.properties
index 352062e..e688b81 100644
--- a/full/src/main/resources/log4j.properties
+++ b/full/src/main/resources/log4j.properties
@@ -6,7 +6,7 @@
 
 #log4j.logger.de.ids_mannheim.korap.service.VirtualCorpusService = error, debugLog
 #log4j.logger.de.ids_mannheim.korap.web.controller.AuthenticationController = debug, debugLog, stdout
-#log4j.logger.de.ids_mannheim.korap.resource.rewrite.CollectionRewrite= stdout, debugLog
+#log4j.logger.de.ids_mannheim.korap.service.UserGroupService= stdout, debugLog
 
 # Direct log messages to stdout
 log4j.appender.stdout=org.apache.log4j.ConsoleAppender
diff --git a/full/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java b/full/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java
index 537f299..818f215 100644
--- a/full/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java
+++ b/full/src/test/java/de/ids_mannheim/korap/web/controller/UserGroupControllerTest.java
@@ -236,6 +236,7 @@
                 node.at("/errors/0/1").asText());
     }
 
+    // EM: same as cancel invitation
     private void testDeletePendingMember () throws UniformInterfaceException,
             ClientHandlerException, KustvaktException {
         // dory delete pearl
@@ -270,14 +271,14 @@
                 .delete(ClientResponse.class);
 
         String entity = response.getEntity(String.class);
-        System.out.println(entity);
+        //        System.out.println(entity);
         JsonNode node = JsonUtils.readTree(entity);
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
-        assertEquals(StatusCodes.DB_ENTRY_DELETED,
+        assertEquals(StatusCodes.GROUP_MEMBER_DELETED,
                 node.at("/errors/0/0").asInt());
-        assertEquals("pearl has already been deleted from the group.",
+        assertEquals("pearl has already been deleted from the group dory group",
                 node.at("/errors/0/1").asText());
-        assertEquals("pearl", node.at("/errors/0/2").asText());
+        assertEquals("[pearl, dory group]", node.at("/errors/0/2").asText());
     }
 
     private void testDeleteGroup (String groupId)
@@ -442,7 +443,7 @@
         //        System.out.println(entity);
         JsonNode node = JsonUtils.readTree(entity);
         assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
-        assertEquals(StatusCodes.DB_ENTRY_EXISTS,
+        assertEquals(StatusCodes.GROUP_MEMBER_EXISTS,
                 node.at("/errors/0/0").asInt());
         assertEquals("Username marlin with status PENDING exists in user-group "
                 + "dory group", node.at("/errors/0/1").asText());
@@ -469,8 +470,8 @@
                                 "pass"))
                 .entity(userGroup).post(ClientResponse.class);
 
-//        String entity = response.getEntity(String.class);
-//        System.out.println(entity);
+                String entity = response.getEntity(String.class);
+                System.out.println(entity);
         assertEquals(Status.OK.getStatusCode(), response.getStatus());
 
         // check member
@@ -625,4 +626,62 @@
         assertEquals(1, node.size());
     }
 
+    @Test
+    public void testUnsubscribeDeletedMember ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
+        // pearl unsubscribes from dory group 
+        MultivaluedMap<String, String> form = new MultivaluedMapImpl();
+        // dory group
+        form.add("groupId", "2");
+
+        ClientResponse response = resource().path("group").path("unsubscribe")
+                .type(MediaType.APPLICATION_FORM_URLENCODED)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        handler.createBasicAuthorizationHeaderValue("pearl",
+                                "pass"))
+                .entity(form).post(ClientResponse.class);
+
+        String entity = response.getEntity(String.class);
+        //        System.out.println(entity);
+        JsonNode node = JsonUtils.readTree(entity);
+        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
+        assertEquals(StatusCodes.GROUP_MEMBER_DELETED,
+                node.at("/errors/0/0").asInt());
+        assertEquals("pearl has already been deleted from the group dory group",
+                node.at("/errors/0/1").asText());
+        assertEquals("[pearl, dory group]", node.at("/errors/0/2").asText());
+    }
+    
+    @Test
+    public void testUnsubscribePendingMember ()
+            throws UniformInterfaceException, ClientHandlerException,
+            KustvaktException {
+
+        JsonNode node = retrieveUserGroups("marlin");
+        assertEquals(2, node.size());
+        
+        MultivaluedMap<String, String> form = new MultivaluedMapImpl();
+        // dory group
+        form.add("groupId", "2");
+
+        ClientResponse response = resource().path("group").path("unsubscribe")
+                .type(MediaType.APPLICATION_FORM_URLENCODED)
+                .header(HttpHeaders.X_FORWARDED_FOR, "149.27.0.32")
+                .header(Attributes.AUTHORIZATION,
+                        handler.createBasicAuthorizationHeaderValue("marlin",
+                                "pass"))
+                .entity(form).post(ClientResponse.class);
+
+        String entity = response.getEntity(String.class);
+        //        System.out.println(entity);
+        assertEquals(Status.OK.getStatusCode(), response.getStatus());
+
+         node = retrieveUserGroups("marlin");
+        assertEquals(1, node.size());
+        
+        // invite marlin to dory group to set back the GroupMemberStatus.PENDING
+        testInviteDeletedMember();
+    }
 }
diff --git a/full/src/test/resources/test-config.xml b/full/src/test/resources/test-config.xml
index b416f1c..01f4e75 100644
--- a/full/src/test/resources/test-config.xml
+++ b/full/src/test/resources/test-config.xml
@@ -75,6 +75,12 @@
 		<property name="url" value="${jdbc.url}" />
 		<property name="username" value="${jdbc.username}" />
 		<property name="password" value="${jdbc.password}" />
+		<property name="connectionProperties">
+            <props>
+                <prop key="date_string_format">yyyy-MM-dd HH:mm:ss</prop>
+            </props>
+        </property>
+        
 		<!-- Sqlite can only have a single connection -->
 		<property name="suppressClose">
 			<value>true</value>