diff --git a/modules/fbs-core/api/src/main/resources/migrations/20_group_field_added.sql b/modules/fbs-core/api/src/main/resources/migrations/20_group_field_added.sql new file mode 100644 index 000000000..04f8d04ce --- /dev/null +++ b/modules/fbs-core/api/src/main/resources/migrations/20_group_field_added.sql @@ -0,0 +1,21 @@ +BEGIN; + + +CREATE TABLE IF NOT EXISTS `fbs`.`group` ( + `group_id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL, + `name` VARCHAR(100) NOT NULL, + `membership` INT NOT NULL, + `visible` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`group_id`), + FOREIGN KEY (`course_id`) REFERENCES `fbs`.`course`(`course_id`), + UNIQUE INDEX `groups_groupid_courseid_uindex` (`group_id`, `course_id`)) + ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +INSERT INTO migration (number) VALUES (20); + +COMMIT; + + diff --git a/modules/fbs-core/api/src/main/resources/migrations/21_user_group_field_added.sql b/modules/fbs-core/api/src/main/resources/migrations/21_user_group_field_added.sql new file mode 100644 index 000000000..22a786f3e --- /dev/null +++ b/modules/fbs-core/api/src/main/resources/migrations/21_user_group_field_added.sql @@ -0,0 +1,51 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS `fbs`.`user_group` ( + `user_id` INT NOT NULL, + `course_id` INT NOT NULL, + `group_id` INT NOT NULL, + PRIMARY KEY (`user_id`, `course_id`, `group_id`), + INDEX `user_has_groups_users_user_id_fk` (`user_id` ASC), + CONSTRAINT `user_has_groups_users_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `fbs`.`user` (`user_id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `user_group_course_course_id_fk` + FOREIGN KEY (`course_id`) + REFERENCES `fbs`.`course` (`course_id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `user_group_group_group_id_fk` + FOREIGN KEY (`group_id`) + REFERENCES `fbs`.`group` (`group_id`) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE = InnoDB + DEFAULT CHARACTER SET = utf8mb4 + COLLATE = utf8mb4_0900_ai_ci; + +-- ----------------------------------------------------- +-- trigger to check if user is participant of the course +-- ----------------------------------------------------- + +CREATE TRIGGER check_user_course_before_insert +BEFORE INSERT ON `user_group` +FOR EACH ROW +BEGIN + DECLARE user_in_course INT; + SELECT COUNT(*) + INTO user_in_course + FROM user_course + WHERE user_id = NEW.user_id AND course_id = NEW.course_id; + + IF user_in_course = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Der Nutzer gehört nicht zum angegebenen Kurs.'; + END IF; +END; + + +INSERT INTO migration (number) VALUES (21); + +COMMIT; + diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupController.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupController.scala new file mode 100644 index 000000000..46b734ca1 --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupController.scala @@ -0,0 +1,153 @@ +package de.thm.ii.fbs.controller + +import com.fasterxml.jackson.databind.JsonNode +import de.thm.ii.fbs.controller.exception.{BadRequestException, ForbiddenException, ResourceNotFoundException} +import de.thm.ii.fbs.model.{Group, CourseRole, GlobalRole} +import de.thm.ii.fbs.services.persistence._ +import de.thm.ii.fbs.services.security.AuthService +import de.thm.ii.fbs.util.JsonWrapper._ + +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation._ + +/** + * Controller to manage rest api calls for a group resource. + */ + +@RestController +@CrossOrigin +@RequestMapping (path = Array("/api/v1/courses/{cid}/groups"), produces = Array(MediaType.APPLICATION_JSON_VALUE)) +class GroupController{ + @Autowired + private val groupService: GroupService = null + @Autowired + private val authService: AuthService = null + @Autowired + private val courseRegistrationService: CourseRegistrationService = null + + /** + * Get a group list + * + * @param cid Course Id + * @param ignoreHidden optional filter to filter only for visible groups + * @param req http request + * @param res http response + * @return group list + */ + @GetMapping(value = Array("")) + @ResponseBody + def getAll(@PathVariable ("cid") cid: Integer, @RequestParam(value = "visible", required = false) + ignoreHidden: Boolean, req: HttpServletRequest, res: HttpServletResponse): List[Group] = { + val user = authService.authorize(req, res) + val someCourseRole = courseRegistrationService.getParticipants(cid).find(_.user.id == user.id).map(_.role) + (user.globalRole, someCourseRole) match { + case (GlobalRole.ADMIN | GlobalRole.MODERATOR, _) | (_, Some(CourseRole.DOCENT)) => + val groupList = groupService.getAll(cid, ignoreHidden = false) + groupList + case _ => throw new ForbiddenException() + } + } + + /** + * Create a new group + * + * @param cid Course Id + * @param req http request + * @param res http response + * @param body contains JSON request + * @return JSON + */ + @PostMapping(value = Array(""), consumes = Array(MediaType.APPLICATION_JSON_VALUE)) + @ResponseBody + def create(@PathVariable ("cid") cid: Int, req: HttpServletRequest, res: HttpServletResponse, @RequestBody body: JsonNode): Group = { + val user = authService.authorize(req, res) + val someCourseRole = courseRegistrationService.getParticipants(cid).find(_.user.id == user.id).map(_.role) + if (!(user.globalRole == GlobalRole.ADMIN || user.globalRole == GlobalRole.MODERATOR || someCourseRole.contains(CourseRole.DOCENT))) { + throw new ForbiddenException() + } + val name = Option(body.get("name")).map(_.asText()) + val membership = Option(body.get("membership")).map(_.asInt()) + val visible = Option(body.get("visible")).map(_.asBoolean()) + (name, membership, visible) match { + case (Some(name), Some (membership), Some(visible)) + => groupService.create(Group(0, cid, name, membership, visible)) + case _ => throw new BadRequestException("Malformed Request Body") + } + } + + /** + * Get a single group by id + * + * @param cid Course Id + * @param gid Group id + * @param req http request + * @param res http response + * @return A single group + */ + @GetMapping(value = Array("/{gid}")) + @ResponseBody + def getOne(@PathVariable ("cid") cid: Integer, @PathVariable("gid") gid: Integer, req: HttpServletRequest, res: HttpServletResponse): Group = { + val user = authService.authorize(req, res) + val someCourseRole = courseRegistrationService.getParticipants(cid).find(_.user.id == user.id).map(_.role) + + groupService.get(cid, gid) match { + case Some(group) => if (!(user.globalRole == GlobalRole.ADMIN || user.globalRole == GlobalRole.MODERATOR || someCourseRole.contains(CourseRole.DOCENT))) { + throw new ForbiddenException() + } else { + group + } + case _ => throw new ResourceNotFoundException() + } + } + + /** + * Update a single group by id + * + * @param cid Course id + * @param gid Group id + * @param req http request + * @param res http response + * @param body Request Body + */ + @PutMapping(value = Array("/{gid}")) + def update(@PathVariable ("cid") cid: Integer, @PathVariable("gid") gid: Integer, req: HttpServletRequest, res: HttpServletResponse, + @RequestBody body: JsonNode): Unit = { + val user = authService.authorize(req, res) + val someCourseRole = courseRegistrationService.getParticipants(cid).find(_.user.id == user.id).map(_.role) + + (user.globalRole, someCourseRole) match { + case (GlobalRole.ADMIN | GlobalRole.MODERATOR, _) | (_, Some(CourseRole.DOCENT)) => + (body.retrive("name").asText(), + body.retrive("membership").asInt(), + body.retrive("visible").asBool() + ) match { + case (Some(name), Some (membership), visible) + => groupService.update(cid, gid, Group(gid, cid, name, membership, visible.getOrElse(true))) + case _ => throw new BadRequestException("Malformed Request Body") + } + case _ => throw new ForbiddenException() + } + } + + /** + * Delete course + * + * @param cid Course id + * @param gid Group id + * @param req http request + * @param res http response + */ + @DeleteMapping(value = Array("/{gid}")) + def delete(@PathVariable ("cid") cid: Integer, @PathVariable("gid") gid: Integer, req: HttpServletRequest, res: HttpServletResponse): Unit = { + val user = authService.authorize(req, res) + val someCourseRole = courseRegistrationService.getParticipants(cid).find(_.user.id == user.id).map(_.role) + + (user.globalRole, someCourseRole) match { + case (GlobalRole.ADMIN | GlobalRole.MODERATOR, _) | (_, Some(CourseRole.DOCENT)) => + groupService.delete(cid, gid) + case _ => throw new ForbiddenException() + } + } +} diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupRegistrationController.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupRegistrationController.scala new file mode 100644 index 000000000..7de518adb --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/GroupRegistrationController.scala @@ -0,0 +1,145 @@ +package de.thm.ii.fbs.controller + +import de.thm.ii.fbs.controller.exception.{ForbiddenException, MembershipExceededException, ResourceNotFoundException} +import de.thm.ii.fbs.model.{CourseRole, GlobalRole, Group, Participant} +import de.thm.ii.fbs.services.persistence._ +import de.thm.ii.fbs.services.security.AuthService + +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation._ + +/** + * Controller to manage rest api calls for group registration and group members. + */ + +@RestController +@CrossOrigin +@RequestMapping(path = Array("/api/v1"), produces = Array(MediaType.APPLICATION_JSON_VALUE)) +class GroupRegistrationController { + @Autowired + private val authService: AuthService = null + @Autowired + private val courseRegistrationService: CourseRegistrationService = null + @Autowired + private val groupService: GroupService = null + @Autowired + private val groupRegistrationService: GroupRegistrationService = null + + /** + * Add a user to a group within a course + * + * @param cid Course id + * @param gid Group id + * @param uid User id + * @param req http request + * @param res http response + */ + @PutMapping(value = Array("/courses/{cid}/groups/{gid}/users/{uid}"), consumes = Array(MediaType.APPLICATION_JSON_VALUE)) + def addUserToGroup(@PathVariable("cid") cid: Int, @PathVariable("gid") gid: Int, @PathVariable("uid") uid: Int, + req: HttpServletRequest, res: HttpServletResponse): Unit = { + val user = authService.authorize(req, res) + val hasGlobalPrivileges = user.hasRole(GlobalRole.ADMIN, GlobalRole.MODERATOR) + val hasCoursePrivileges = courseRegistrationService.getCoursePrivileges(user.id).getOrElse(cid, CourseRole.STUDENT) == CourseRole.DOCENT + if (hasGlobalPrivileges || hasCoursePrivileges || user.id == uid) { + //Check if the group is full + val currentMembership = groupRegistrationService.getGroupMembership(cid, gid) + val group = groupService.get(cid, gid) + group match { + case Some(group) => val maxMembership: Int = group.membership + if (currentMembership < maxMembership) { + groupRegistrationService.addUserToGroup(uid, cid, gid) + } else { + throw new MembershipExceededException() + } + case _ => throw new ResourceNotFoundException() + } + } else { + throw new ForbiddenException() + } + } + + /** + * Remove a user from a group + * + * @param uid User id + * @param cid Course id + * @param gid Group id + * @param req http request + * @param res http response + */ + @DeleteMapping(value = Array("/courses/{cid}/groups/{gid}/users/{uid}")) + def removeUserFromGroup(@PathVariable("uid") uid: Int, @PathVariable("cid") cid: Int, @PathVariable("gid") gid: Int, + req: HttpServletRequest, res: HttpServletResponse): Unit = { + val user = authService.authorize(req, res) + val hasGlobalPrivileges = user.hasRole(GlobalRole.ADMIN, GlobalRole.MODERATOR) + val hasCoursePrivileges = courseRegistrationService.getCoursePrivileges(user.id).getOrElse(cid, CourseRole.STUDENT) == CourseRole.DOCENT + if (hasGlobalPrivileges || hasCoursePrivileges || user.id == uid) { + groupRegistrationService.removeUserFromGroup(uid, cid, gid) + } else { + throw new ForbiddenException() + } + } + + /** + * Remove all users from a group + * + * @param cid Course id + * @param gid Group id + * @param req http request + * @param res http response + */ + @DeleteMapping(value = Array("/courses/{cid}/groups/{gid}/users")) + def removeUserFromGroup(@PathVariable("cid") cid: Int, @PathVariable("gid") gid: Int, req: HttpServletRequest, res: HttpServletResponse): Unit = { + val user = authService.authorize(req, res) + val hasGlobalPrivileges = user.hasRole(GlobalRole.ADMIN, GlobalRole.MODERATOR) + val hasCoursePrivileges = courseRegistrationService.getCoursePrivileges(user.id).getOrElse(cid, CourseRole.STUDENT) == CourseRole.DOCENT + if (hasGlobalPrivileges || hasCoursePrivileges) { + groupRegistrationService.removeAllUsersFromGroup(cid, gid) + } else { + throw new ForbiddenException() + } + } + + /** + * Retrieve all groups of a specific user + * + * @param uid User id + * @param req http request + * @param res http response + * @return List of Groups + */ + @GetMapping(value = Array("/users/{uid}/groups")) + @ResponseBody + def getUserGroups(@PathVariable("uid") uid: Integer, req: HttpServletRequest, res: HttpServletResponse): List[Group] = { + val user = authService.authorize(req, res) + val hasGlobalPrivileges = user.hasRole(GlobalRole.ADMIN, GlobalRole.MODERATOR) + if (hasGlobalPrivileges || user.id == uid) { + groupRegistrationService.getUserGroups(uid, ignoreHidden = false) + } else { + throw new ForbiddenException() + } + } + + /** + * Get all course participants which are part of a group + * @param cid Course id + * @param gid Group id + * @param req http request + * @param res http response + * @return List of course participants + */ + @GetMapping(value = Array("/courses/{cid}/groups/{gid}/participants")) + @ResponseBody + def getMembers(@PathVariable("cid") cid: Integer, @PathVariable("gid") gid: Int, req: HttpServletRequest, res: HttpServletResponse): List[Participant] = { + val user = authService.authorize(req, res) + val hasGlobalPrivileges = user.hasRole(GlobalRole.ADMIN, GlobalRole.MODERATOR) + val hasCoursePrivileges = courseRegistrationService.getCoursePrivileges(user.id).getOrElse(cid, CourseRole.STUDENT) == CourseRole.DOCENT + if (hasGlobalPrivileges || hasCoursePrivileges) { + groupRegistrationService.getMembers(cid, gid) + } else { + throw new ForbiddenException() + } + } +} diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/exception/MembershipExceededException.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/exception/MembershipExceededException.scala new file mode 100644 index 000000000..3d5d6ac77 --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/controller/exception/MembershipExceededException.scala @@ -0,0 +1,11 @@ +package de.thm.ii.fbs.controller.exception + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +/** + * ConflictException 400 + * @param message A human readable String + */ +@ResponseStatus(value = HttpStatus.CONFLICT) +class MembershipExceededException(message: String = "Die Gruppe ist voll.") extends RuntimeException(message) diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/model/Group.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/model/Group.scala new file mode 100644 index 000000000..bf4630690 --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/model/Group.scala @@ -0,0 +1,12 @@ +package de.thm.ii.fbs.model + +/** + * A group + * @param id The id of the group + * @param courseId course to which the group belongs + * @param name Name of the group + * @param membership The max number of members + * @param visible The visibility of the group, false = invisible + */ + +case class Group(id: Int, courseId: Int, name: String, membership: Int, visible: Boolean = true) diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupRegistrationService.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupRegistrationService.scala new file mode 100644 index 000000000..e047bc8ba --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupRegistrationService.scala @@ -0,0 +1,110 @@ +package de.thm.ii.fbs.services.persistence + +import de.thm.ii.fbs.model._ +import de.thm.ii.fbs.util.DB +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +import java.sql.ResultSet +import org.springframework.jdbc.core.RowMapper + +/** + * Handles group registration and participants. + */ +@Component +class GroupRegistrationService { + @Autowired + private implicit val jdbc: JdbcTemplate = null + + /** + * Add a user to a group + * + * @param uid User id + * @param cid Course id + * @param gid Group id + * @return True if successful + */ + def addUserToGroup(uid: Int, cid: Int, gid: Int): Boolean = + 1 == DB.update("INSERT INTO user_group (user_id, course_id, group_id) VALUES (?,?,?);", uid, cid, gid) + + /** + * Remove a user from a group + * + * @param uid User id + * @param cid Course id + * @param gid Group id + * @return True if successfully deregistered + */ + def removeUserFromGroup(uid: Int, cid: Int, gid: Int): Boolean = + 1 == DB.update("DELETE FROM user_group WHERE user_id = ? AND course_id = ? AND group_id = ?", uid, cid, gid) + + /** + * Remove all users from a group + * + * @param cid Course id + * @param gid Group id + * @return True if successfully deregistered + */ + def removeAllUsersFromGroup(cid: Int, gid: Int): Boolean = + 1 == DB.update("DELETE FROM user_group WHERE course_id = ? AND group_id = ?", cid, gid) + + /** + * Retrieve all groups of a specific user + * + * @param uid User id + * @param ignoreHidden True if hidden groups should be ignored + * @return List of groups + */ + def getUserGroups(uid: Int, ignoreHidden: Boolean = true): List[Group] = DB.query( + "SELECT g.group_id, g.course_id, g.name, g.membership, g.visible " + + "FROM `group` g JOIN user_group ug ON g.group_id = ug.group_id WHERE ug.user_id = ? ORDER BY g.course_id ASC" + + (if (ignoreHidden) " AND g.visible = 1" else ""), + (res, _) => parseResult(res), uid) + + /** + * Get all members of a group + * + * @param cid Course id + * @param gid Group id + * @return List of members + */ + def getMembers(cid: Int, gid: Int): List[Participant] = DB.query( + "SELECT u.user_id, u.prename, u.surname, u.email, u.username, u.alias, u.global_role, uc.course_role " + + "FROM user u " + + "JOIN user_course uc ON u.user_id = uc.user_id " + + "JOIN user_group ug ON uc.course_id = ug.course_id AND uc.user_id = ug.user_id " + + "WHERE u.deleted = 0 AND ug.course_id = ? AND ug.group_id = ?", + (res, _) => Participant(parseUserResult(res), CourseRole.parse(res.getInt("course_role"))), cid, gid) + + /** + * Gets current number of members of a group + * + * @param cid Course id + * @param gid Group id + * @return Number of members + */ + def getGroupMembership(cid: Int, gid: Int): Int = { + val groupMembershipRowMapper: RowMapper[Int] = (rs: ResultSet, _) => rs.getInt(1) + val sql = "SELECT COUNT(*) FROM user_group WHERE course_id = ? AND group_id = ?" + jdbc.queryForObject(sql, groupMembershipRowMapper, cid, gid) + } + + private def parseResult(res: ResultSet): Group = Group( + id = res.getInt("group_id"), + courseId = res.getInt("course_id"), + name = res.getString("name"), + membership = res.getInt("membership"), + visible = res.getBoolean("visible"), + ) + + private def parseUserResult(res: ResultSet): User = new User( + prename = res.getString("prename"), + surname = res.getString("surname"), + email = res.getString("email"), + username = res.getString("username"), + globalRole = GlobalRole.parse(res.getInt("global_role")), + alias = Option(res.getString("alias")), + id = res.getInt("user_id") + ) +} diff --git a/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupService.scala b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupService.scala new file mode 100644 index 000000000..5d3fd8ec4 --- /dev/null +++ b/modules/fbs-core/api/src/main/scala/de/thm/ii/fbs/services/persistence/GroupService.scala @@ -0,0 +1,87 @@ +package de.thm.ii.fbs.services.persistence + +import java.math.BigInteger +import java.sql.{ResultSet, SQLException} + +import de.thm.ii.fbs.model.Group +import de.thm.ii.fbs.util._ +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +/** + * Handles the creation, deletion and modifications of groups persistent state. + */ +@Component +class GroupService{ + @Autowired + private implicit val jdbc: JdbcTemplate = null + + /** + * Get a group list + * + * @param cid Course id + * @param ignoreHidden If true only visible groups will be returned + * @return List of groups + */ + def getAll(cid: Int, ignoreHidden: Boolean = true): List[Group] = DB.query( + s"SELECT group_id, course_id, name, membership, visible FROM `group` WHERE" + (if (ignoreHidden) " visible = 1 AND" else "") + s" course_id = $cid", + (res, _) => parseResult(res) + ) + + /** + * Create a new group + * + * @param group The group + * @return The created group with id + */ + def create(group: Group): Group = { + DB.insert("INSERT INTO `group` (course_id, name, membership, visible) VALUES (?, ?, ?, ?);", group.courseId, group.name, group.membership, group.visible) + .map(gk => gk(0).asInstanceOf[BigInteger].intValue()) + .flatMap(id => get(group.courseId, id)) match { + case Some(group) => group + case None => throw new SQLException("Group could not be created") + } + } + + /** + * Get a single group by id + * + * @param cid Course id + * @param gid Group id + * @return The found Group + */ + def get(cid: Int, gid: Int): Option[Group] = DB.query( + "SELECT group_id, course_id, name, membership, visible FROM `group` WHERE course_id = ? AND group_id = ?", + (res, _) => parseResult(res), cid, gid).headOption + + /** + * Update a single group by id + * + * @param cid Course id + * @param gid Group id + * @param group The group + * @return True if successful + */ + def update(cid: Int, gid: Int, group: Group): Boolean = { + 1 == DB.update("UPDATE `group` SET name = ?, membership = ?, visible = ? WHERE course_id = ? AND group_id = ?", + group.name, group.membership, group.visible, cid, gid) + } + + /** + * Delete a single group by id + * + * param cid Course id + * @param gid Group id + * @return True if successful + */ + def delete(cid: Int, gid: Int): Boolean = 1 == DB.update("DELETE FROM `group` WHERE course_id = ? AND group_id = ?", cid, gid) + + private def parseResult(res: ResultSet): Group = Group( + id = res.getInt("group_id"), + courseId = res.getInt("course_id"), + name = res.getString("name"), + membership = res.getInt("membership"), + visible = res.getBoolean("visible"), + ) +}