From 606f12d96c741cb26e7136945714b81c5684ea56 Mon Sep 17 00:00:00 2001
From: Daniel Gerhardt <code@dgerhardt.net>
Date: Sat, 27 Jul 2019 09:22:56 +0200
Subject: [PATCH] Refactor handling of content groups

Content groups are now a separate entity with their own repository and
service and endpoint. A content group can no longer be set when creating
a new content. Instead the new API endpoint has to be used to add the
content to a group.
---
 .../thm/arsnova/config/PersistenceConfig.java |   7 +
 .../arsnova/controller/RoomController.java    |  20 ++-
 .../de/thm/arsnova/model/ContentGroup.java    | 122 ++++++++++++++++++
 src/main/java/de/thm/arsnova/model/Room.java  | 101 +--------------
 .../persistence/ContentGroupRepository.java   |  29 +++++
 .../CouchDbContentGroupRepository.java        |  56 ++++++++
 .../ApplicationPermissionEvaluator.java       |  33 +++++
 .../arsnova/service/ContentGroupService.java  |  92 +++++++++++++
 .../arsnova/service/ContentServiceImpl.java   |  82 ++++--------
 .../thm/arsnova/service/RoomServiceImpl.java  |  28 ----
 .../resources/couchdb/ContentGroup.design.js  |  22 ++++
 .../arsnova/config/TestPersistanceConfig.java |   6 +
 12 files changed, 411 insertions(+), 187 deletions(-)
 create mode 100644 src/main/java/de/thm/arsnova/model/ContentGroup.java
 create mode 100644 src/main/java/de/thm/arsnova/persistence/ContentGroupRepository.java
 create mode 100644 src/main/java/de/thm/arsnova/persistence/couchdb/CouchDbContentGroupRepository.java
 create mode 100644 src/main/java/de/thm/arsnova/service/ContentGroupService.java
 create mode 100644 src/main/resources/couchdb/ContentGroup.design.js

diff --git a/src/main/java/de/thm/arsnova/config/PersistenceConfig.java b/src/main/java/de/thm/arsnova/config/PersistenceConfig.java
index 1f831776d..f8e6bdf55 100644
--- a/src/main/java/de/thm/arsnova/config/PersistenceConfig.java
+++ b/src/main/java/de/thm/arsnova/config/PersistenceConfig.java
@@ -32,6 +32,7 @@ import de.thm.arsnova.config.properties.CouchDbProperties;
 import de.thm.arsnova.model.serialization.CouchDbObjectMapperFactory;
 import de.thm.arsnova.persistence.AnswerRepository;
 import de.thm.arsnova.persistence.CommentRepository;
+import de.thm.arsnova.persistence.ContentGroupRepository;
 import de.thm.arsnova.persistence.ContentRepository;
 import de.thm.arsnova.persistence.LogEntryRepository;
 import de.thm.arsnova.persistence.MotdRepository;
@@ -41,6 +42,7 @@ import de.thm.arsnova.persistence.StatisticsRepository;
 import de.thm.arsnova.persistence.UserRepository;
 import de.thm.arsnova.persistence.couchdb.CouchDbAnswerRepository;
 import de.thm.arsnova.persistence.couchdb.CouchDbCommentRepository;
+import de.thm.arsnova.persistence.couchdb.CouchDbContentGroupRepository;
 import de.thm.arsnova.persistence.couchdb.CouchDbContentRepository;
 import de.thm.arsnova.persistence.couchdb.CouchDbLogEntryRepository;
 import de.thm.arsnova.persistence.couchdb.CouchDbMotdRepository;
@@ -140,6 +142,11 @@ public class PersistenceConfig {
 		return new CouchDbContentRepository(couchDbConnector(), false);
 	}
 
+	@Bean
+	public ContentGroupRepository contentGroupRepository() throws Exception {
+		return new CouchDbContentGroupRepository(couchDbConnector(), false);
+	}
+
 	@Bean
 	public AnswerRepository answerRepository() throws Exception {
 		return new CouchDbAnswerRepository(couchDbConnector(), false);
diff --git a/src/main/java/de/thm/arsnova/controller/RoomController.java b/src/main/java/de/thm/arsnova/controller/RoomController.java
index 67eb68cba..5357089b8 100644
--- a/src/main/java/de/thm/arsnova/controller/RoomController.java
+++ b/src/main/java/de/thm/arsnova/controller/RoomController.java
@@ -22,12 +22,15 @@ import java.util.Set;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import de.thm.arsnova.model.ContentGroup;
 import de.thm.arsnova.model.Room;
+import de.thm.arsnova.service.ContentGroupService;
 import de.thm.arsnova.service.RoomService;
 
 @RestController
@@ -36,12 +39,16 @@ public class RoomController extends AbstractEntityController<Room> {
 	protected static final String REQUEST_MAPPING = "/room";
 	private static final String GET_MODERATORS_MAPPING = DEFAULT_ID_MAPPING + "/moderator";
 	private static final String MODERATOR_MAPPING = DEFAULT_ID_MAPPING + "/moderator/{userId}";
+	private static final String CONTENTGROUP_MAPPING = DEFAULT_ID_MAPPING + "/contentgroup/{groupName}";
+	private static final String CONTENTGROUP_ADD_MAPPING = CONTENTGROUP_MAPPING + "/{contentId}";
 
 	private RoomService roomService;
+	private ContentGroupService contentGroupService;
 
-	public RoomController(final RoomService roomService) {
+	public RoomController(final RoomService roomService, final ContentGroupService contentGroupService) {
 		super(roomService);
 		this.roomService = roomService;
+		this.contentGroupService = contentGroupService;
 	}
 
 	@Override
@@ -78,4 +85,15 @@ public class RoomController extends AbstractEntityController<Room> {
 		room.getModerators().removeIf(m -> m.getUserId().equals(userId));
 		roomService.update(room);
 	}
+
+	@GetMapping(CONTENTGROUP_MAPPING)
+	public ContentGroup getContentGroup(@PathVariable final String id, @PathVariable final String groupName) {
+		return contentGroupService.getByRoomIdAndName(id, groupName);
+	}
+
+	@PostMapping(CONTENTGROUP_ADD_MAPPING)
+	public void addContentToGroup(@PathVariable final String id, @PathVariable final String groupName,
+			@RequestBody final String contentId) {
+		contentGroupService.addContentToGroup(id, groupName, contentId);
+	}
 }
diff --git a/src/main/java/de/thm/arsnova/model/ContentGroup.java b/src/main/java/de/thm/arsnova/model/ContentGroup.java
new file mode 100644
index 000000000..25ad8733c
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/model/ContentGroup.java
@@ -0,0 +1,122 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.	 If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.model;
+
+import com.fasterxml.jackson.annotation.JsonView;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import org.springframework.core.style.ToStringCreator;
+
+import de.thm.arsnova.model.serialization.View;
+
+public class ContentGroup extends Entity {
+	@NotEmpty
+	private String roomId;
+
+	@NotBlank
+	private String name;
+
+	private Set<String> contentIds;
+	private boolean autoSort;
+
+	public ContentGroup() {
+
+	}
+
+	public ContentGroup(final String roomId, final String name) {
+		this.roomId = roomId;
+		this.name = name;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public String getRoomId() {
+		return roomId;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public void setRoomId(final String roomId) {
+		this.roomId = roomId;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public String getName() {
+		return this.name;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public void setName(final String name) {
+		this.name = name;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public Set<String> getContentIds() {
+		if (contentIds == null) {
+			contentIds = new HashSet<>();
+		}
+
+		return contentIds;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public void setContentIds(final Set<String> contentIds) {
+		this.contentIds = contentIds;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public boolean isAutoSort() {
+		return autoSort;
+	}
+
+	@JsonView({View.Persistence.class, View.Public.class})
+	public void setAutoSort(final boolean autoSort) {
+		this.autoSort = autoSort;
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(name, contentIds, autoSort);
+	}
+
+	@Override
+	public boolean equals(final Object o) {
+		if (this == o) {
+			return true;
+		}
+		if (o == null || getClass() != o.getClass()) {
+			return false;
+		}
+		final ContentGroup that = (ContentGroup) o;
+
+		return autoSort == that.autoSort
+			&& Objects.equals(name, that.name)
+			&& Objects.equals(contentIds, that.contentIds);
+	}
+
+	@Override
+	public String toString() {
+		return new ToStringCreator(this)
+			.append("name", name)
+			.append("contentIds", contentIds)
+			.append("autoSort", autoSort)
+			.toString();
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/model/Room.java b/src/main/java/de/thm/arsnova/model/Room.java
index 689eae0a8..cf5f69e31 100644
--- a/src/main/java/de/thm/arsnova/model/Room.java
+++ b/src/main/java/de/thm/arsnova/model/Room.java
@@ -23,8 +23,6 @@ import java.util.HashSet;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.function.Function;
-import java.util.stream.Collectors;
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotEmpty;
 import org.springframework.core.style.ToStringCreator;
@@ -32,77 +30,6 @@ import org.springframework.core.style.ToStringCreator;
 import de.thm.arsnova.model.serialization.View;
 
 public class Room extends Entity {
-	public static class ContentGroup {
-		@NotBlank
-		private String name;
-
-		private Set<String> contentIds;
-		private boolean autoSort;
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public String getName() {
-			return this.name;
-		}
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public void setName(final String name) {
-			this.name = name;
-		}
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public Set<String> getContentIds() {
-			if (contentIds == null) {
-				contentIds = new HashSet<>();
-			}
-
-			return contentIds;
-		}
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public void setContentIds(final Set<String> contentIds) {
-			this.contentIds = contentIds;
-		}
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public boolean isAutoSort() {
-			return autoSort;
-		}
-
-		@JsonView({View.Persistence.class, View.Public.class})
-		public void setAutoSort(final boolean autoSort) {
-			this.autoSort = autoSort;
-		}
-
-		@Override
-		public int hashCode() {
-			return Objects.hash(name, contentIds, autoSort);
-		}
-
-		@Override
-		public boolean equals(final Object o) {
-			if (this == o) {
-				return true;
-			}
-			if (o == null || getClass() != o.getClass()) {
-				return false;
-			}
-			final ContentGroup that = (ContentGroup) o;
-
-			return autoSort == that.autoSort
-					&& Objects.equals(name, that.name)
-					&& Objects.equals(contentIds, that.contentIds);
-		}
-
-		@Override
-		public String toString() {
-			return new ToStringCreator(this)
-					.append("name", name)
-					.append("contentIds", contentIds)
-					.append("autoSort", autoSort)
-					.toString();
-		}
-	}
-
 	public static class Moderator {
 		public enum Role {
 			EDITING_MODERATOR,
@@ -468,7 +395,6 @@ public class Room extends Entity {
 
 	private String description;
 	private boolean closed;
-	private Set<ContentGroup> contentGroups;
 	private Set<Moderator> moderators;
 	private Settings settings;
 	private Author author;
@@ -537,28 +463,6 @@ public class Room extends Entity {
 		this.closed = closed;
 	}
 
-	@JsonView({View.Persistence.class, View.Public.class})
-	public Set<ContentGroup> getContentGroups() {
-		if (contentGroups == null) {
-			contentGroups = new HashSet<>();
-		}
-
-		return contentGroups;
-	}
-
-	public Map<String, ContentGroup> getContentGroupsAsMap() {
-		return getContentGroups().stream().collect(Collectors.toMap(ContentGroup::getName, Function.identity()));
-	}
-
-	@JsonView({View.Persistence.class, View.Public.class})
-	public void setContentGroups(final Set<ContentGroup> contentGroups) {
-		this.contentGroups = contentGroups;
-	}
-
-	public void setContentGroupsFromMap(final Map<String, ContentGroup> groups) {
-		this.contentGroups = new HashSet<>(groups.values());
-	}
-
 	@JsonView(View.Persistence.class)
 	public Set<Moderator> getModerators() {
 		if (moderators == null) {
@@ -641,8 +545,8 @@ public class Room extends Entity {
 	 *
 	 * <p>
 	 * The following fields of <tt>Room</tt> are excluded from equality checks:
-	 * {@link #contentGroups}, {@link #settings}, {@link #author}, {@link #poolProperties}, {@link #extensions},
-	 * {@link #attachments}, {@link #statistics}.
+	 * {@link #settings}, {@link #author}, {@link #poolProperties}, {@link #extensions}, {@link #attachments},
+	 * {@link #statistics}.
 	 * </p>
 	 */
 	@Override
@@ -672,7 +576,6 @@ public class Room extends Entity {
 				.append("abbreviation", abbreviation)
 				.append("description", description)
 				.append("closed", closed)
-				.append("contentGroups", contentGroups)
 				.append("settings", settings)
 				.append("author", author)
 				.append("poolProperties", poolProperties)
diff --git a/src/main/java/de/thm/arsnova/persistence/ContentGroupRepository.java b/src/main/java/de/thm/arsnova/persistence/ContentGroupRepository.java
new file mode 100644
index 000000000..6ee6a87b0
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/persistence/ContentGroupRepository.java
@@ -0,0 +1,29 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.	 If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.persistence;
+
+import java.util.List;
+
+import de.thm.arsnova.model.ContentGroup;
+
+public interface ContentGroupRepository extends CrudRepository<ContentGroup, String> {
+	ContentGroup findByRoomIdAndName(String roomId, String name);
+
+	List<ContentGroup> findByRoomId(String roomId);
+}
diff --git a/src/main/java/de/thm/arsnova/persistence/couchdb/CouchDbContentGroupRepository.java b/src/main/java/de/thm/arsnova/persistence/couchdb/CouchDbContentGroupRepository.java
new file mode 100644
index 000000000..8edebb3ec
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/persistence/couchdb/CouchDbContentGroupRepository.java
@@ -0,0 +1,56 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.	 If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.persistence.couchdb;
+
+import java.util.List;
+import org.ektorp.ComplexKey;
+import org.ektorp.CouchDbConnector;
+
+import de.thm.arsnova.model.ContentGroup;
+import de.thm.arsnova.persistence.ContentGroupRepository;
+
+public class CouchDbContentGroupRepository extends CouchDbCrudRepository<ContentGroup>
+		implements ContentGroupRepository {
+	public CouchDbContentGroupRepository(final CouchDbConnector db, final boolean createIfNotExists) {
+		super(ContentGroup.class, db, "by_id", createIfNotExists);
+	}
+
+	@Override
+	public ContentGroup findByRoomIdAndName(final String roomId, final String name) {
+		final List<ContentGroup> contentGroupList = db.queryView(createQuery("by_roomid_name")
+						.key(ComplexKey.of(roomId, name))
+						.includeDocs(true)
+						.reduce(false),
+				ContentGroup.class);
+
+		return !contentGroupList.isEmpty() ? contentGroupList.get(0) : null;
+	}
+
+	@Override
+	public List<ContentGroup> findByRoomId(final String roomId) {
+		final List<ContentGroup> contentGroups = db.queryView(createQuery("by_roomid_name")
+						.startKey(ComplexKey.of(roomId))
+						.endKey(ComplexKey.of(roomId, ComplexKey.emptyObject()))
+						.includeDocs(true)
+						.reduce(false),
+				ContentGroup.class);
+
+		return contentGroups;
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/security/ApplicationPermissionEvaluator.java b/src/main/java/de/thm/arsnova/security/ApplicationPermissionEvaluator.java
index d91e45747..66a50e5be 100644
--- a/src/main/java/de/thm/arsnova/security/ApplicationPermissionEvaluator.java
+++ b/src/main/java/de/thm/arsnova/security/ApplicationPermissionEvaluator.java
@@ -35,11 +35,13 @@ import de.thm.arsnova.config.properties.SecurityProperties;
 import de.thm.arsnova.model.Answer;
 import de.thm.arsnova.model.Comment;
 import de.thm.arsnova.model.Content;
+import de.thm.arsnova.model.ContentGroup;
 import de.thm.arsnova.model.Motd;
 import de.thm.arsnova.model.Room;
 import de.thm.arsnova.model.UserProfile;
 import de.thm.arsnova.persistence.AnswerRepository;
 import de.thm.arsnova.persistence.CommentRepository;
+import de.thm.arsnova.persistence.ContentGroupRepository;
 import de.thm.arsnova.persistence.ContentRepository;
 import de.thm.arsnova.persistence.MotdRepository;
 import de.thm.arsnova.persistence.RoomRepository;
@@ -67,6 +69,9 @@ public class ApplicationPermissionEvaluator implements PermissionEvaluator {
 	@Autowired
 	private ContentRepository contentRepository;
 
+	@Autowired
+	ContentGroupRepository contentGroupRepository;
+
 	@Autowired
 	private AnswerRepository answerRepository;
 
@@ -96,6 +101,8 @@ public class ApplicationPermissionEvaluator implements PermissionEvaluator {
 						&& hasRoomPermission(userId, ((Room) targetDomainObject), permission.toString()))
 				|| (targetDomainObject instanceof Content
 						&& hasContentPermission(userId, ((Content) targetDomainObject), permission.toString()))
+				|| (targetDomainObject instanceof ContentGroup
+				&& hasContentGroupPermission(userId, ((ContentGroup) targetDomainObject), permission.toString()))
 				|| (targetDomainObject instanceof Answer
 						&& hasAnswerPermission(userId, ((Answer) targetDomainObject), permission.toString()))
 				|| (targetDomainObject instanceof Comment
@@ -132,6 +139,10 @@ public class ApplicationPermissionEvaluator implements PermissionEvaluator {
 			case "content":
 				final Content targetContent = contentRepository.findOne(targetId.toString());
 				return targetContent != null && hasContentPermission(userId, targetContent, permission.toString());
+			case "contentgroup":
+				final ContentGroup targetContentGroup = contentGroupRepository.findOne(targetId.toString());
+				return targetContentGroup != null
+						&& hasContentGroupPermission(userId, targetContentGroup, permission.toString());
 			case "answer":
 				final Answer targetAnswer = answerRepository.findOne(targetId.toString());
 				return targetAnswer != null && hasAnswerPermission(userId, targetAnswer, permission.toString());
@@ -211,6 +222,28 @@ public class ApplicationPermissionEvaluator implements PermissionEvaluator {
 		}
 	}
 
+	private boolean hasContentGroupPermission(
+			final String userId,
+			final ContentGroup targetContentGroup,
+			final String permission) {
+		final Room room = roomRepository.findOne(targetContentGroup.getRoomId());
+		if (room == null) {
+			return false;
+		}
+
+		switch (permission) {
+			case "read":
+				return !room.isClosed() || hasUserIdRoomModeratingPermission(room, userId);
+			case "create":
+			case "update":
+			case "delete":
+				return room.getOwnerId().equals(userId)
+						|| hasUserIdRoomModeratorRole(room, userId, Room.Moderator.Role.EDITING_MODERATOR);
+			default:
+				return false;
+		}
+	}
+
 	private boolean hasAnswerPermission(
 			final String userId,
 			final Answer targetAnswer,
diff --git a/src/main/java/de/thm/arsnova/service/ContentGroupService.java b/src/main/java/de/thm/arsnova/service/ContentGroupService.java
new file mode 100644
index 000000000..a227a984f
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/service/ContentGroupService.java
@@ -0,0 +1,92 @@
+/*
+ * This file is part of ARSnova Backend.
+ * Copyright (C) 2012-2019 The ARSnova Team and Contributors
+ *
+ * ARSnova Backend is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * ARSnova Backend is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.	 If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.thm.arsnova.service;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.event.EventListener;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.security.access.annotation.Secured;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.Validator;
+
+import de.thm.arsnova.event.BeforeDeletionEvent;
+import de.thm.arsnova.model.Content;
+import de.thm.arsnova.model.ContentGroup;
+import de.thm.arsnova.model.Room;
+import de.thm.arsnova.persistence.ContentGroupRepository;
+
+@Service
+public class ContentGroupService extends DefaultEntityServiceImpl<ContentGroup> {
+	private ContentGroupRepository contentGroupRepository;
+	private ContentService contentService;
+
+	public ContentGroupService(
+			final ContentGroupRepository repository,
+			final ContentService contentService,
+			@Qualifier("defaultJsonMessageConverter")
+			final MappingJackson2HttpMessageConverter jackson2HttpMessageConverter,
+			final Validator validator) {
+		super(ContentGroup.class, repository, jackson2HttpMessageConverter.getObjectMapper(), validator);
+		this.contentGroupRepository = repository;
+		this.contentService = contentService;
+	}
+
+	public ContentGroup getByRoomIdAndName(final String roomId, final String name) {
+		return contentGroupRepository.findByRoomIdAndName(roomId, name);
+	}
+
+	public List<ContentGroup> getByRoomId(final String roomId) {
+		return contentGroupRepository.findByRoomId(roomId);
+	}
+
+	public void addContentToGroup(final String roomId, final String groupName, final String contentId) {
+		ContentGroup contentGroup = getByRoomIdAndName(roomId, groupName);
+		if (contentGroup == null) {
+			contentGroup = new ContentGroup(roomId, groupName);
+			contentGroup.getContentIds().add(contentId);
+			create(contentGroup);
+		} else {
+			contentGroup.getContentIds().add(contentId);
+			update(contentGroup);
+		}
+	}
+
+	public void updateContentGroup(final ContentGroup contentGroup) {
+		if (contentGroup.getContentIds().isEmpty()) {
+			delete(contentGroup);
+		} else {
+			final Set<String> contentIds = StreamSupport.stream(
+					contentService.get(contentGroup.getContentIds()).spliterator(), false)
+						.filter(c -> c.getRoomId().equals(contentGroup.getRoomId()))
+						.map(Content::getId).collect(Collectors.toSet());
+			update(contentGroup);
+		}
+	}
+
+	@EventListener
+	@Secured({"ROLE_USER", "RUN_AS_SYSTEM"})
+	public void handleRoomDeletion(final BeforeDeletionEvent<Room> event) {
+		final Iterable<ContentGroup> contentGroups = contentGroupRepository.findByRoomId(event.getEntity().getId());
+		delete(contentGroups);
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/service/ContentServiceImpl.java b/src/main/java/de/thm/arsnova/service/ContentServiceImpl.java
index 4321993f8..78208b53d 100644
--- a/src/main/java/de/thm/arsnova/service/ContentServiceImpl.java
+++ b/src/main/java/de/thm/arsnova/service/ContentServiceImpl.java
@@ -22,7 +22,6 @@ import java.io.IOException;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -42,8 +41,8 @@ import org.springframework.validation.Validator;
 
 import de.thm.arsnova.event.BeforeDeletionEvent;
 import de.thm.arsnova.model.Content;
+import de.thm.arsnova.model.ContentGroup;
 import de.thm.arsnova.model.Room;
-import de.thm.arsnova.model.Room.ContentGroup;
 import de.thm.arsnova.persistence.AnswerRepository;
 import de.thm.arsnova.persistence.ContentRepository;
 import de.thm.arsnova.persistence.LogEntryRepository;
@@ -64,6 +63,8 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 
 	private ContentRepository contentRepository;
 
+	private ContentGroupService contentGroupService;
+
 	private AnswerService answerService;
 
 	private AnswerRepository answerRepository;
@@ -92,16 +93,17 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 		this.answerService = answerService;
 	}
 
+	@Autowired
+	public void setContentGroupService(final ContentGroupService contentGroupService) {
+		this.contentGroupService = contentGroupService;
+	}
+
 	@Override
 	protected void modifyRetrieved(final Content content) {
 		if (content.getFormat() != Content.Format.TEXT && 0 == content.getState().getRound()) {
 			/* needed for legacy questions whose piRound property has not been set */
 			content.getState().setRound(1);
 		}
-
-		final Room room = roomService.get(content.getRoomId());
-		content.setGroups(room.getContentGroups().stream()
-				.map(Room.ContentGroup::getName).filter(g -> !g.isEmpty()).collect(Collectors.toSet()));
 	}
 
 	/* FIXME: caching */
@@ -120,13 +122,8 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 
 	@Override
 	public Iterable<Content> getByRoomIdAndGroup(final String roomId, final String group) {
-		final Room room = roomService.get(roomId);
-		Room.ContentGroup contentGroup = null;
-		for (final Room.ContentGroup cg : room.getContentGroups()) {
-			if (cg.getName().equals(group)) {
-				contentGroup = cg;
-			}
-		}
+		final ContentGroup contentGroup = contentGroupService.getByRoomIdAndName(roomId, group);
+
 		if (contentGroup == null) {
 			throw new NotFoundException("Content group does not exist.");
 		}
@@ -142,13 +139,8 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 
 	@Override
 	public int countByRoomIdAndGroup(final String roomId, final String group) {
-		final Room room = roomService.get(roomId);
-		Room.ContentGroup contentGroup = null;
-		for (final Room.ContentGroup cg : room.getContentGroups()) {
-			if (cg.getName().equals(group)) {
-				contentGroup = cg;
-			}
-		}
+		final ContentGroup contentGroup = contentGroupService.getByRoomIdAndName(roomId, group);
+
 		if (contentGroup == null) {
 			throw new NotFoundException("Content group does not exist.");
 		}
@@ -179,22 +171,6 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 		*/
 	}
 
-	@Override
-	protected void finalizeCreate(final Content content) {
-		/* Update content groups of room */
-		final Room room = roomService.get(content.getRoomId());
-		final Map<String, Room.ContentGroup> groups = room.getContentGroupsAsMap();
-		for (final String groupName : content.getGroups()) {
-			final Room.ContentGroup group = groups.getOrDefault(groupName, new Room.ContentGroup());
-			groups.put(groupName, group);
-			group.getContentIds().add(content.getId());
-			group.setName(groupName);
-			group.setAutoSort(true);
-		}
-		room.setContentGroupsFromMap(groups);
-		roomService.update(room);
-	}
-
 	@Override
 	protected void prepareUpdate(final Content content) {
 		final User user = userService.getCurrentUser();
@@ -220,25 +196,6 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 
 	@Override
 	protected void finalizeUpdate(final Content content) {
-		/* Update content groups of room */
-		final Room room = roomService.get(content.getRoomId());
-		final Set<String> contentsGroupNames = content.getGroups();
-		final Set<String> allGroupNames = new HashSet<>(contentsGroupNames);
-		final Map<String, Room.ContentGroup> groups = room.getContentGroupsAsMap();
-		allGroupNames.addAll(groups.keySet());
-		for (final String groupName : allGroupNames) {
-			final Room.ContentGroup group = groups.getOrDefault(groupName, new Room.ContentGroup());
-			if (contentsGroupNames.contains(groupName)) {
-				group.getContentIds().add(content.getId());
-				group.setName(groupName);
-				group.setAutoSort(true);
-			} else {
-				group.getContentIds().remove(content.getId());
-			}
-		}
-		room.setContentGroupsFromMap(groups);
-		roomService.update(room);
-
 		/* TODO: not sure yet how to refactor this code - we need access to the old and new entity
 		if (!oldContent.getState().isVisible() && content.getState().isVisible()) {
 			final UnlockQuestionEvent event = new UnlockQuestionEvent(this, room, content);
@@ -252,11 +209,18 @@ public class ContentServiceImpl extends DefaultEntityServiceImpl<Content> implem
 
 	@Override
 	protected void prepareDelete(final Content content) {
-		final Room room = roomService.get(content.getRoomId());
-		for (final ContentGroup group : room.getContentGroups()) {
-			group.getContentIds().remove(content.getId());
+		final List<ContentGroup> contentGroups = contentGroupService.getByRoomId(content.getRoomId());
+		for (final ContentGroup contentGroup : contentGroups) {
+			final Set<String> ids = contentGroup.getContentIds();
+			if (ids.contains(content.getId())) {
+				ids.remove(content.getId());
+				if (!ids.isEmpty()) {
+					contentGroupService.update(contentGroup);
+				} else {
+					contentGroupService.delete(contentGroup);
+				}
+			}
 		}
-		roomService.update(room);
 	}
 
 	@Override
diff --git a/src/main/java/de/thm/arsnova/service/RoomServiceImpl.java b/src/main/java/de/thm/arsnova/service/RoomServiceImpl.java
index 0ddfa42fb..2ea4ae30e 100644
--- a/src/main/java/de/thm/arsnova/service/RoomServiceImpl.java
+++ b/src/main/java/de/thm/arsnova/service/RoomServiceImpl.java
@@ -23,9 +23,7 @@ import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
@@ -167,32 +165,6 @@ public class RoomServiceImpl extends DefaultEntityServiceImpl<Room> implements R
 		}
 	}
 
-	/**
-	 * Adds a default content group with contents that have no other content group assigned.
-	 *
-	 * @param room The Room to be modified
-	 */
-	@Override
-	protected void modifyRetrieved(final Room room) {
-		// creates a set from all room content groups
-		final Set<String> cidsWithGroup = room.getContentGroups().stream()
-				.map(Room.ContentGroup::getContentIds)
-				.flatMap(ids -> ids.stream())
-				.collect(Collectors.toSet());
-
-		final Set<String> cids = new HashSet<>(contentRepository.findIdsByRoomId(room.getId()));
-		cids.removeAll(cidsWithGroup);
-
-		if (!cids.isEmpty()) {
-			final Set<Room.ContentGroup> cgs = room.getContentGroups();
-			final Room.ContentGroup defaultGroup = new Room.ContentGroup();
-			defaultGroup.setContentIds(cids);
-			defaultGroup.setAutoSort(true);
-			defaultGroup.setName("");
-			cgs.add(defaultGroup);
-		}
-	}
-
 	@Override
 	public Room join(final String id, final UUID socketId) {
 		final Room room = null != id ? get(id) : null;
diff --git a/src/main/resources/couchdb/ContentGroup.design.js b/src/main/resources/couchdb/ContentGroup.design.js
new file mode 100644
index 000000000..8592b2926
--- /dev/null
+++ b/src/main/resources/couchdb/ContentGroup.design.js
@@ -0,0 +1,22 @@
+var designDoc = {
+	"_id": "_design/ContentGroup",
+	"language": "javascript",
+	"views": {
+		"by_id": {
+			"map": function (doc) {
+				if (doc.type === "ContentGroup") {
+					emit(doc._id, {_rev: doc._rev});
+				}
+			},
+			"reduce": "_count"
+		},
+		"by_roomid_name": {
+			"map": function (doc) {
+				if (doc.type === "ContentGroup") {
+					emit([doc.roomId, doc.name], {_rev: doc._rev});
+				}
+			},
+			"reduce": "_count"
+		}
+	}
+};
diff --git a/src/test/java/de/thm/arsnova/config/TestPersistanceConfig.java b/src/test/java/de/thm/arsnova/config/TestPersistanceConfig.java
index 92e5af312..89974031a 100644
--- a/src/test/java/de/thm/arsnova/config/TestPersistanceConfig.java
+++ b/src/test/java/de/thm/arsnova/config/TestPersistanceConfig.java
@@ -26,6 +26,7 @@ import org.springframework.context.annotation.Profile;
 import de.thm.arsnova.persistence.AnswerRepository;
 import de.thm.arsnova.persistence.AttachmentRepository;
 import de.thm.arsnova.persistence.CommentRepository;
+import de.thm.arsnova.persistence.ContentGroupRepository;
 import de.thm.arsnova.persistence.ContentRepository;
 import de.thm.arsnova.persistence.LogEntryRepository;
 import de.thm.arsnova.persistence.MotdRepository;
@@ -62,6 +63,11 @@ public class TestPersistanceConfig {
 		return Mockito.mock(ContentRepository.class);
 	}
 
+	@Bean
+	public ContentGroupRepository contentGroupRepository() {
+		return Mockito.mock(ContentGroupRepository.class);
+	}
+
 	@Bean
 	public AnswerRepository answerRepository() {
 		return Mockito.mock(AnswerRepository.class);
-- 
GitLab