diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js index 16df63a48..0d61a041a 100644 --- a/backend/src/constants/categories.js +++ b/backend/src/constants/categories.js @@ -4,7 +4,7 @@ export const CATEGORIES_MAX = 3 export const categories = [ { - icon: 'users', + icon: 'networking', name: 'networking', description: 'Kooperation, Aktionsbündnisse, Solidarität, Hilfe', }, @@ -14,12 +14,12 @@ export const categories = [ description: 'Bauen, Lebensgemeinschaften, Tiny Houses, Gemüsegarten', }, { - icon: 'lightbulb', + icon: 'energy', name: 'energy', description: 'Öl, Gas, Kohle, Wind, Wasserkraft, Biogas, Atomenergie, ...', }, { - icon: 'smile', + icon: 'psyche', name: 'psyche', description: 'Seele, Gefühle, Glück', }, @@ -34,7 +34,7 @@ export const categories = [ description: 'Menschenrechte, Gesetze, Verordnungen', }, { - icon: 'money', + icon: 'finance', name: 'finance', description: 'Geld, Finanzsystem, Alternativwährungen, ...', }, @@ -44,7 +44,7 @@ export const categories = [ description: 'Familie, Pädagogik, Schule, Prägung', }, { - icon: 'suitcase', + icon: 'mobility', name: 'mobility', description: 'Reise, Verkehr, Elektromobilität', }, @@ -54,48 +54,48 @@ export const categories = [ description: 'Handel, Konsum, Marketing, Lebensmittel, Lieferketten, ...', }, { - icon: 'angellist', + icon: 'peace', name: 'peace', description: 'Krieg, Militär, soziale Verteidigung, Waffen, Cyberattacken', }, { - icon: 'university', + icon: 'politics', name: 'politics', description: 'Demokratie, Mitbestimmung, Wahlen, Korruption, Parteien', }, { - icon: 'tree', + icon: 'nature', name: 'nature', description: 'Tiere, Pflanzen, Landwirtschaft, Ökologie, Artenvielfalt', }, { - icon: 'graduation-cap', + icon: 'science', name: 'science', description: 'Bildung, Hochschule, Publikationen, ...', }, { - icon: 'medkit', + icon: 'health', name: 'health', description: 'Medizin, Ernährung, WHO, Impfungen, Schadstoffe, ...', }, { - icon: 'desktop', + icon: 'media', name: 'it-and-media', description: 'Nachrichten, Manipulation, Datenschutz, Überwachung, Datenkraken, AI, Software, Apps', }, { - icon: 'heart-o', + icon: 'spirituality', name: 'spirituality', description: 'Religion, Werte, Ethik', }, { - icon: 'music', + icon: 'culture', name: 'culture', description: 'Kunst, Theater, Musik, Fotografie, Film', }, { - icon: 'ellipsis-h', + icon: 'miscellaneous', name: 'miscellaneous', description: '', }, diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index 301146280..623f467a4 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -106,6 +106,17 @@ export const joinGroupMutation = gql` } ` +export const leaveGroupMutation = gql` + mutation ($groupId: ID!, $userId: ID!) { + LeaveGroup(groupId: $groupId, userId: $userId) { + id + name + slug + myRoleInGroup + } + } +` + export const changeGroupMemberRoleMutation = gql` mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) { ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) { @@ -120,36 +131,8 @@ export const changeGroupMemberRoleMutation = gql` // ------ queries export const groupQuery = gql` - query ( - $isMember: Boolean - $id: ID - $name: String - $slug: String - $createdAt: String - $updatedAt: String - $about: String - $description: String - $locationName: String - $first: Int - $offset: Int - $orderBy: [_GroupOrdering] - $filter: _GroupFilter - ) { - Group( - isMember: $isMember - id: $id - name: $name - slug: $slug - createdAt: $createdAt - updatedAt: $updatedAt - about: $about - description: $description - locationName: $locationName - first: $first - offset: $offset - orderBy: $orderBy - filter: $filter - ) { + query ($isMember: Boolean, $id: ID, $slug: String) { + Group(isMember: $isMember, id: $id, slug: $slug) { id name slug @@ -178,8 +161,8 @@ export const groupQuery = gql` ` export const groupMembersQuery = gql` - query ($id: ID!, $first: Int, $offset: Int, $orderBy: [_UserOrdering], $filter: _UserFilter) { - GroupMembers(id: $id, first: $first, offset: $offset, orderBy: $orderBy, filter: $filter) { + query ($id: ID!) { + GroupMembers(id: $id) { id name slug diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index e588e1b62..8f3d6947a 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -191,6 +191,36 @@ const isAllowedToJoinGroup = rule({ } }) +const isAllowedToLeaveGroup = rule({ + cache: 'no_cache', +})(async (_parent, args, { user, driver }) => { + if (!(user && user.id)) return false + const { groupId, userId } = args + if (user.id !== userId) return false + const session = driver.session() + const readTxPromise = session.readTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + RETURN group {.*}, member {.*, myRoleInGroup: membership.role} + `, + { groupId, userId }, + ) + return { + group: transactionResponse.records.map((record) => record.get('group'))[0], + member: transactionResponse.records.map((record) => record.get('member'))[0], + } + }) + try { + const { group, member } = await readTxPromise + return !!group && !!member && !!member.myRoleInGroup && member.myRoleInGroup !== 'owner' + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +}) + const isAuthor = rule({ cache: 'no_cache', })(async (_parent, args, { user, driver }) => { @@ -284,6 +314,7 @@ export default shield( CreateGroup: isAuthenticated, UpdateGroup: isAllowedToChangeGroupSettings, JoinGroup: isAllowedToJoinGroup, + LeaveGroup: isAllowedToLeaveGroup, ChangeGroupMemberRole: isAllowedToChangeGroupMemberRole, CreatePost: isAuthenticated, UpdatePost: isAuthor, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 4a84b524f..ea955afa0 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -4,21 +4,26 @@ import CONFIG from '../../config' import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' -import Resolver from './helpers/Resolver' +import Resolver, { + removeUndefinedNullValuesFromObject, + convertObjectToCypherMapLiteral, +} from './helpers/Resolver' import { mergeImage } from './images/images' import createOrUpdateLocations from './users/location' export default { Query: { Group: async (_object, params, context, _resolveInfo) => { - const { id: groupId, isMember } = params + const { isMember, id, slug } = params + const matchParams = { id, slug } + removeUndefinedNullValuesFromObject(matchParams) const session = context.driver.session() const readTxResultPromise = session.readTransaction(async (txc) => { - const groupIdCypher = groupId ? ` {id: "${groupId}"}` : '' + const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true) let groupCypher if (isMember === true) { groupCypher = ` - MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupIdCypher}) + MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher}) WITH group, membership WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) RETURN group {.*, myRole: membership.role} @@ -26,7 +31,7 @@ export default { } else { if (isMember === false) { groupCypher = ` - MATCH (group:Group${groupIdCypher}) + MATCH (group:Group${groupMatchParamsCypher}) WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group)) WITH group WHERE group.groupType IN ['public', 'closed'] @@ -34,7 +39,7 @@ export default { ` } else { groupCypher = ` - MATCH (group:Group${groupIdCypher}) + MATCH (group:Group${groupMatchParamsCypher}) OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) WITH group, membership WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner']) @@ -244,6 +249,27 @@ export default { session.close() } }, + LeaveGroup: async (_parent, params, context, _resolveInfo) => { + const { groupId, userId } = params + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const leaveGroupCypher = ` + MATCH (member:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + DELETE membership + RETURN member {.*, myRoleInGroup: NULL} + ` + const transactionResponse = await transaction.run(leaveGroupCypher, { groupId, userId }) + const [member] = await transactionResponse.records.map((record) => record.get('member')) + return member + }) + try { + return await writeTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, ChangeGroupMemberRole: async (_parent, params, context, _resolveInfo) => { const { groupId, userId, roleInGroup } = params const session = context.driver.session() diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index e9b38cc2b..b4653edaf 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -4,6 +4,7 @@ import { createGroupMutation, updateGroupMutation, joinGroupMutation, + leaveGroupMutation, changeGroupMemberRoleMutation, groupMembersQuery, groupQuery, @@ -17,6 +18,12 @@ const neode = getNeode() let authenticatedUser let user +let noMemberUser +let pendingMemberUser +let usualMemberUser +let adminMemberUser +let ownerMemberUser +let secondOwnerMemberUser const categoryIds = ['cat9', 'cat4', 'cat15'] const descriptionAdditional100 = @@ -76,6 +83,169 @@ const seedBasicsAndClearAuthentication = async () => { authenticatedUser = null } +const seedComplexScenarioAndClearAuthentication = async () => { + await seedBasicsAndClearAuthentication() + // create users + noMemberUser = await Factory.build( + 'user', + { + id: 'none-member-user', + name: 'None Member TestUser', + }, + { + email: 'none-member-user@example.org', + password: '1234', + }, + ) + pendingMemberUser = await Factory.build( + 'user', + { + id: 'pending-member-user', + name: 'Pending Member TestUser', + }, + { + email: 'pending-member-user@example.org', + password: '1234', + }, + ) + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + adminMemberUser = await Factory.build( + 'user', + { + id: 'admin-member-user', + name: 'Admin Member TestUser', + }, + { + email: 'admin-member-user@example.org', + password: '1234', + }, + ) + ownerMemberUser = await Factory.build( + 'user', + { + id: 'owner-member-user', + name: 'Owner Member TestUser', + }, + { + email: 'owner-member-user@example.org', + password: '1234', + }, + ) + secondOwnerMemberUser = await Factory.build( + 'user', + { + id: 'second-owner-member-user', + name: 'Second Owner Member TestUser', + }, + { + email: 'second-owner-member-user@example.org', + password: '1234', + }, + ) + // create groups + // public-group + authenticatedUser = await usualMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'public-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-closed-group', + }, + }) + await mutate({ + mutation: joinGroupMutation, + variables: { + groupId: 'public-group', + userId: 'owner-of-hidden-group', + }, + }) + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'closed-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, + }, + }) + // hidden-group + authenticatedUser = await adminMemberUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'hidden-group', + name: 'Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'admin-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'hidden-group', + userId: 'second-owner-member-user', + roleInGroup: 'usual', + }, + }) + + authenticatedUser = null +} + beforeAll(async () => { await cleanDatabase() }) @@ -503,6 +673,72 @@ describe('in mode', () => { }) }) + describe('with given slug', () => { + describe("slug = 'the-best-group'", () => { + it('finds only the listed group with this slug', async () => { + const result = await query({ + query: groupQuery, + variables: { slug: 'the-best-group' }, + }) + expect(result).toMatchObject({ + data: { + Group: [ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + ], + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("slug = 'third-investigative-journalism-group'", () => { + it("finds only the hidden group where I'm 'usual' member", async () => { + const result = await query({ + query: groupQuery, + variables: { slug: 'third-investigative-journalism-group' }, + }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(1) + }) + }) + + describe("slug = 'second-investigative-journalism-group'", () => { + it("finds no hidden group where I'm 'pending' member", async () => { + const result = await query({ + query: groupQuery, + variables: { slug: 'second-investigative-journalism-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + + describe("slug = 'investigative-journalism-group'", () => { + it("finds no hidden group where I'm not(!) a member at all", async () => { + const result = await query({ + query: groupQuery, + variables: { slug: 'investigative-journalism-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + }) + describe('isMember = true', () => { it('finds only listed groups where user is member', async () => { const result = await query({ query: groupQuery, variables: { isMember: true } }) @@ -966,8 +1202,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -999,8 +1235,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1032,8 +1268,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1075,8 +1311,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1108,8 +1344,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1173,8 +1409,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1210,8 +1446,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1247,8 +1483,8 @@ describe('in mode', () => { }) it('finds all members', async () => { - const result = await mutate({ - mutation: groupMembersQuery, + const result = await query({ + query: groupMembersQuery, variables, }) expect(result).toMatchObject({ @@ -1305,162 +1541,8 @@ describe('in mode', () => { }) describe('ChangeGroupMemberRole', () => { - let pendingMemberUser - let usualMemberUser - let adminMemberUser - let ownerMemberUser - let secondOwnerMemberUser - beforeAll(async () => { - await seedBasicsAndClearAuthentication() - // create users - pendingMemberUser = await Factory.build( - 'user', - { - id: 'pending-member-user', - name: 'Pending Member TestUser', - }, - { - email: 'pending-member-user@example.org', - password: '1234', - }, - ) - usualMemberUser = await Factory.build( - 'user', - { - id: 'usual-member-user', - name: 'Usual Member TestUser', - }, - { - email: 'usual-member-user@example.org', - password: '1234', - }, - ) - adminMemberUser = await Factory.build( - 'user', - { - id: 'admin-member-user', - name: 'Admin Member TestUser', - }, - { - email: 'admin-member-user@example.org', - password: '1234', - }, - ) - ownerMemberUser = await Factory.build( - 'user', - { - id: 'owner-member-user', - name: 'Owner Member TestUser', - }, - { - email: 'owner-member-user@example.org', - password: '1234', - }, - ) - secondOwnerMemberUser = await Factory.build( - 'user', - { - id: 'second-owner-member-user', - name: 'Second Owner Member TestUser', - }, - { - email: 'second-owner-member-user@example.org', - password: '1234', - }, - ) - // create groups - // public-group - authenticatedUser = await usualMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'public-group', - name: 'The Best Group', - about: 'We will change the world!', - description: 'Some description' + descriptionAdditional100, - groupType: 'public', - actionRadius: 'regional', - categoryIds, - }, - }) - await mutate({ - mutation: joinGroupMutation, - variables: { - groupId: 'public-group', - userId: 'owner-of-closed-group', - }, - }) - await mutate({ - mutation: joinGroupMutation, - variables: { - groupId: 'public-group', - userId: 'owner-of-hidden-group', - }, - }) - // closed-group - authenticatedUser = await ownerMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'closed-group', - name: 'Uninteresting Group', - about: 'We will change nothing!', - description: 'We love it like it is!?' + descriptionAdditional100, - groupType: 'closed', - actionRadius: 'national', - categoryIds, - }, - }) - // hidden-group - authenticatedUser = await adminMemberUser.toJson() - await mutate({ - mutation: createGroupMutation, - variables: { - id: 'hidden-group', - name: 'Investigative Journalism Group', - about: 'We will change all.', - description: 'We research …' + descriptionAdditional100, - groupType: 'hidden', - actionRadius: 'global', - categoryIds, - }, - }) - // 'JoinGroup' mutation does not work in hidden groups so we join them by 'ChangeGroupMemberRole' through the owner - await mutate({ - mutation: changeGroupMemberRoleMutation, - variables: { - groupId: 'hidden-group', - userId: 'admin-member-user', - roleInGroup: 'usual', - }, - }) - await mutate({ - mutation: changeGroupMemberRoleMutation, - variables: { - groupId: 'hidden-group', - userId: 'second-owner-member-user', - roleInGroup: 'usual', - }, - }) - await mutate({ - mutation: changeGroupMemberRoleMutation, - variables: { - groupId: 'hidden-group', - userId: 'admin-member-user', - roleInGroup: 'usual', - }, - }) - await mutate({ - mutation: changeGroupMemberRoleMutation, - variables: { - groupId: 'hidden-group', - userId: 'second-owner-member-user', - roleInGroup: 'usual', - }, - }) - - authenticatedUser = null + await seedComplexScenarioAndClearAuthentication() }) afterAll(async () => { @@ -2264,6 +2346,241 @@ describe('in mode', () => { }) }) + describe('LeaveGroup', () => { + beforeAll(async () => { + await seedComplexScenarioAndClearAuthentication() + // closed-group + authenticatedUser = await ownerMemberUser.toJson() + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'pending-member-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'usual-member-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'admin-member-user', + roleInGroup: 'admin', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'closed-group', + userId: 'second-owner-member-user', + roleInGroup: 'owner', + }, + }) + + authenticatedUser = null + }) + + afterAll(async () => { + await cleanDatabase() + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + groupId: 'not-existing-group', + userId: 'current-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('authenticated', () => { + describe('in all group types', () => { + describe('here "closed-group" for example', () => { + const memberInGroup = async (userId, groupId) => { + const result = await query({ + query: groupMembersQuery, + variables: { + id: groupId, + }, + }) + return result.data && result.data.GroupMembers + ? !!result.data.GroupMembers.find((member) => member.id === userId) + : null + } + + beforeEach(async () => { + authenticatedUser = null + variables = { + groupId: 'closed-group', + } + }) + + describe('left by "pending-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(true) + authenticatedUser = await pendingMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'pending-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'pending-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('pending-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "usual-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(true) + authenticatedUser = await usualMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'usual-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'usual-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('usual-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "admin-member-user"', () => { + it('has "null" as membership role, was in the group, and left the group', async () => { + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(true) + authenticatedUser = await adminMemberUser.toJson() + await expect( + mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'admin-member-user', + }, + }), + ).resolves.toMatchObject({ + data: { + LeaveGroup: { + id: 'admin-member-user', + myRoleInGroup: null, + }, + }, + errors: undefined, + }) + authenticatedUser = await ownerMemberUser.toJson() + expect(await memberInGroup('admin-member-user', 'closed-group')).toBe(false) + }) + }) + + describe('left by "owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "second-owner-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await secondOwnerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'second-owner-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('left by "none-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await noMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'none-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "owner-member-user" try to leave member "usual-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await ownerMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'usual-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "usual-member-user" try to leave member "admin-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await usualMemberUser.toJson() + const { errors } = await mutate({ + mutation: leaveGroupMutation, + variables: { + ...variables, + userId: 'admin-member-user', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + }) + }) + }) + }) + describe('UpdateGroup', () => { beforeAll(async () => { await seedBasicsAndClearAuthentication() diff --git a/backend/src/schema/resolvers/helpers/Resolver.js b/backend/src/schema/resolvers/helpers/Resolver.js index f2861e7a0..6e8211521 100644 --- a/backend/src/schema/resolvers/helpers/Resolver.js +++ b/backend/src/schema/resolvers/helpers/Resolver.js @@ -121,3 +121,25 @@ export default function Resolver(type, options = {}) { } return result } + +export const removeUndefinedNullValuesFromObject = (obj) => { + Object.keys(obj).forEach((key) => { + if ([undefined, null].includes(obj[key])) { + delete obj[key] + } + }) +} + +export const convertObjectToCypherMapLiteral = (params, addSpaceInfrontIfMapIsNotEmpty = false) => { + // I have found no other way yet. maybe "apoc.convert.fromJsonMap(key)" can help, but couldn't get it how, see: https://stackoverflow.com/questions/43217823/neo4j-cypher-inline-conversion-of-string-to-a-map + // result looks like: '{id: "g0", slug: "yoga"}' + const paramsEntries = Object.entries(params) + let mapLiteral = '' + paramsEntries.forEach((ele, index) => { + mapLiteral += index === 0 ? '{' : '' + mapLiteral += `${ele[0]}: "${ele[1]}"` + mapLiteral += index < paramsEntries.length - 1 ? ', ' : '}' + }) + mapLiteral = (addSpaceInfrontIfMapIsNotEmpty && mapLiteral.length > 0 ? ' ' : '') + mapLiteral + return mapLiteral +} diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index beb2cddb3..d88eafdae 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -20,16 +20,22 @@ export default { const result = await transaction.run( ` MATCH (user:User {id: $id}) - WITH user, [(user)<-[:OWNED_BY]-(medium:SocialMedia) | properties(medium) ] as media - RETURN user {.*, socialMedia: media } as user + OPTIONAL MATCH (category:Category) WHERE NOT ((user)-[:NOT_INTERESTED_IN]->(category)) + OPTIONAL MATCH (cats:Category) + WITH user, [(user)<-[:OWNED_BY]-(medium:SocialMedia) | properties(medium) ] AS media, category, toString(COUNT(cats)) AS categoryCount + RETURN user {.*, socialMedia: media, activeCategories: collect(category.id) } AS user, categoryCount `, { id: user.id }, ) - log(result) - return result.records.map((record) => record.get('user')) + const [categoryCount] = result.records.map((record) => record.get('categoryCount')) + const [currentUser] = result.records.map((record) => record.get('user')) + // frontend expects empty array when all categories are selected + if (currentUser.activeCategories.length === parseInt(categoryCount)) + currentUser.activeCategories = [] + return currentUser }) try { - const [currentUser] = await currentUserTransactionPromise + const currentUser = await currentUserTransactionPromise return currentUser } finally { session.close() diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index 20e9ef9d3..50e6e84bf 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -7,6 +7,7 @@ import { createTestClient } from 'apollo-server-testing' import createServer, { context } from '../../server' import encode from '../../jwt/encode' import { getNeode } from '../../db/neo4j' +import { categories } from '../../constants/categories' const neode = getNeode() let query, mutate, variables, req, user @@ -119,6 +120,7 @@ describe('currentUser', () => { } email role + activeCategories } } ` @@ -173,6 +175,52 @@ describe('currentUser', () => { } await respondsWith(expected) }) + + describe('with categories in DB', () => { + beforeEach(async () => { + await Promise.all( + categories.map(async ({ icon, name }, index) => { + await Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) + }), + ) + }) + + it('returns empty array for all categories', async () => { + await respondsWith({ + data: { + currentUser: expect.objectContaining({ activeCategories: [] }), + }, + }) + }) + + describe('with categories saved for current user', () => { + const saveCategorySettings = gql` + mutation ($activeCategories: [String]) { + saveCategorySettings(activeCategories: $activeCategories) + } + ` + beforeEach(async () => { + await mutate({ + mutation: saveCategorySettings, + variables: { activeCategories: ['cat1', 'cat3', 'cat5', 'cat7'] }, + }) + }) + + it('returns only the saved active categories', async () => { + const result = await query({ query: currentUserQuery, variables }) + expect(result.data.currentUser.activeCategories).toHaveLength(4) + expect(result.data.currentUser.activeCategories).toContain('cat1') + expect(result.data.currentUser.activeCategories).toContain('cat3') + expect(result.data.currentUser.activeCategories).toContain('cat5') + expect(result.data.currentUser.activeCategories).toContain('cat7') + }) + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 23a39a2a1..12f00ffb6 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -286,6 +286,10 @@ export default { { id }, ) }) + + // frontend gives [] when all categories are selected (default) + if (activeCategories.length === 0) return true + const writeTxResultPromise = session.writeTransaction(async (transaction) => { const saveCategorySettingsResponse = await transaction.run( ` diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 7116d2201..d8fce3b29 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -602,9 +602,7 @@ describe('save category settings', () => { const userQuery = gql` query ($id: ID) { User(id: $id) { - activeCategories { - id - } + activeCategories } } ` @@ -631,11 +629,7 @@ describe('save category settings', () => { data: { User: [ { - activeCategories: expect.arrayContaining([ - { id: 'cat1' }, - { id: 'cat3' }, - { id: 'cat5' }, - ]), + activeCategories: expect.arrayContaining(['cat1', 'cat3', 'cat5']), }, ], }, @@ -678,11 +672,11 @@ describe('save category settings', () => { User: [ { activeCategories: expect.arrayContaining([ - { id: 'cat10' }, - { id: 'cat11' }, - { id: 'cat12' }, - { id: 'cat8' }, - { id: 'cat9' }, + 'cat10', + 'cat11', + 'cat12', + 'cat8', + 'cat9', ]), }, ], diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index afb2264ba..084145d1c 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -62,27 +62,19 @@ type Query { Group( isMember: Boolean # if 'undefined' or 'null' then get all groups id: ID - name: String slug: String - createdAt: String - updatedAt: String - about: String - description: String - # groupType: GroupType # test this - # actionRadius: GroupActionRadius # test this - # avatar: ImageInput # test this - locationName: String - first: Int - offset: Int - orderBy: [_GroupOrdering] + # first: Int # not implemented yet + # offset: Int # not implemented yet + # orderBy: [_GroupOrdering] # not implemented yet + # filter: _GroupFilter # not implemented yet ): [Group] GroupMembers( id: ID! - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter + # first: Int # not implemented yet + # offset: Int # not implemented yet + # orderBy: [_UserOrdering] # not implemented yet + # filter: _UserFilter # not implemented yet ): [User] # AvailableGroupTypes: [GroupType]! @@ -126,6 +118,11 @@ type Mutation { userId: ID! ): User + LeaveGroup( + groupId: ID! + userId: ID! + ): User + ChangeGroupMemberRole( groupId: ID! userId: ID! diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 3d71aac78..2861b0fda 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -115,11 +115,11 @@ type User { emotions: [EMOTED] - activeCategories: [Category] @cypher( + activeCategories: [String] @cypher( statement: """ MATCH (category:Category) WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category)) - RETURN category + RETURN collect(category.id) """ ) diff --git a/package.json b/package.json index a36063f04..d7463adac 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "jsonwebtoken": "^8.5.1", "mock-socket": "^9.0.3", "neo4j-driver": "^4.3.4", - "neode": "^0.4.7", + "neode": "^0.4.8", "npm-run-all": "^4.1.5", "rosie": "^2.1.0", "slug": "^6.0.0" diff --git a/webapp/assets/_new/icons/svgs/culture.svg b/webapp/assets/_new/icons/svgs/culture.svg new file mode 100644 index 000000000..d63e38cb4 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/culture.svg @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/webapp/assets/_new/icons/svgs/energy.svg b/webapp/assets/_new/icons/svgs/energy.svg new file mode 100644 index 000000000..5035a5586 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/energy.svg @@ -0,0 +1,14 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/finance.svg b/webapp/assets/_new/icons/svgs/finance.svg new file mode 100644 index 000000000..74081bc6a --- /dev/null +++ b/webapp/assets/_new/icons/svgs/finance.svg @@ -0,0 +1,13 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/health.svg b/webapp/assets/_new/icons/svgs/health.svg new file mode 100644 index 000000000..acf50d7c1 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/health.svg @@ -0,0 +1,7 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/media.svg b/webapp/assets/_new/icons/svgs/media.svg new file mode 100644 index 000000000..d63c98610 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/media.svg @@ -0,0 +1,7 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/miscellaneous.svg b/webapp/assets/_new/icons/svgs/miscellaneous.svg new file mode 100644 index 000000000..07f8dbe3f --- /dev/null +++ b/webapp/assets/_new/icons/svgs/miscellaneous.svg @@ -0,0 +1,13 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/mobility.svg b/webapp/assets/_new/icons/svgs/mobility.svg new file mode 100644 index 000000000..9e36ec21e --- /dev/null +++ b/webapp/assets/_new/icons/svgs/mobility.svg @@ -0,0 +1,11 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/movement.svg b/webapp/assets/_new/icons/svgs/movement.svg index ac5cd9cc0..81052875d 100644 --- a/webapp/assets/_new/icons/svgs/movement.svg +++ b/webapp/assets/_new/icons/svgs/movement.svg @@ -1,20 +1,19 @@ - - - - - - - - - - - + + + + diff --git a/webapp/assets/_new/icons/svgs/nature.svg b/webapp/assets/_new/icons/svgs/nature.svg new file mode 100644 index 000000000..d40250af4 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/nature.svg @@ -0,0 +1,18 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/networking.svg b/webapp/assets/_new/icons/svgs/networking.svg new file mode 100644 index 000000000..b8d35da69 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/networking.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/webapp/assets/_new/icons/svgs/peace.svg b/webapp/assets/_new/icons/svgs/peace.svg new file mode 100644 index 000000000..408601cae --- /dev/null +++ b/webapp/assets/_new/icons/svgs/peace.svg @@ -0,0 +1,8 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/politics.svg b/webapp/assets/_new/icons/svgs/politics.svg new file mode 100644 index 000000000..35322097d --- /dev/null +++ b/webapp/assets/_new/icons/svgs/politics.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/webapp/assets/_new/icons/svgs/psyche.svg b/webapp/assets/_new/icons/svgs/psyche.svg new file mode 100644 index 000000000..8c285d5ca --- /dev/null +++ b/webapp/assets/_new/icons/svgs/psyche.svg @@ -0,0 +1,8 @@ + + + diff --git a/webapp/assets/_new/icons/svgs/save.svg b/webapp/assets/_new/icons/svgs/save.svg new file mode 100644 index 000000000..31c1d8459 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/save.svg @@ -0,0 +1,5 @@ + + +save + + diff --git a/webapp/assets/_new/icons/svgs/science.svg b/webapp/assets/_new/icons/svgs/science.svg new file mode 100644 index 000000000..9d3211223 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/science.svg @@ -0,0 +1,25 @@ + + + + diff --git a/webapp/assets/_new/icons/svgs/spirituality.svg b/webapp/assets/_new/icons/svgs/spirituality.svg new file mode 100644 index 000000000..0c2757071 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/spirituality.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/webapp/components/FollowButton.spec.js b/webapp/components/Button/FollowButton.spec.js similarity index 100% rename from webapp/components/FollowButton.spec.js rename to webapp/components/Button/FollowButton.spec.js diff --git a/webapp/components/FollowButton.vue b/webapp/components/Button/FollowButton.vue similarity index 99% rename from webapp/components/FollowButton.vue rename to webapp/components/Button/FollowButton.vue index 126a3891d..a0807ed6c 100644 --- a/webapp/components/FollowButton.vue +++ b/webapp/components/Button/FollowButton.vue @@ -19,7 +19,6 @@ import { followUserMutation, unfollowUserMutation } from '~/graphql/User' export default { name: 'HcFollowButton', - props: { followId: { type: String, default: null }, isFollowed: { type: Boolean, default: false }, diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue new file mode 100644 index 000000000..993631065 --- /dev/null +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/webapp/components/FilterMenu/CategoriesFilter.spec.js b/webapp/components/FilterMenu/CategoriesFilter.spec.js index ef0f2ad90..813f7b33c 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.spec.js +++ b/webapp/components/FilterMenu/CategoriesFilter.spec.js @@ -15,8 +15,19 @@ describe('CategoriesFilter.vue', () => { 'posts/filteredCategoryIds': jest.fn(() => []), } + const apolloMutationMock = jest.fn().mockResolvedValue({ + data: { saveCategorySettings: true }, + }) + const mocks = { $t: jest.fn((string) => string), + $apollo: { + mutate: apolloMutationMock, + }, + $toast: { + success: jest.fn(), + error: jest.fn(), + }, } const Wrapper = () => { @@ -76,5 +87,14 @@ describe('CategoriesFilter.vue', () => { expect(mutations['posts/RESET_CATEGORIES']).toHaveBeenCalledTimes(1) }) }) + + describe('save categories', () => { + it('calls the API', async () => { + wrapper = await Wrapper() + const saveButton = wrapper.findAll('.categories-filter .sidebar .base-button').at(1) + saveButton.trigger('click') + expect(apolloMutationMock).toBeCalled() + }) + }) }) }) diff --git a/webapp/components/FilterMenu/CategoriesFilter.vue b/webapp/components/FilterMenu/CategoriesFilter.vue index 47e4bcc10..6ed37d4b3 100644 --- a/webapp/components/FilterMenu/CategoriesFilter.vue +++ b/webapp/components/FilterMenu/CategoriesFilter.vue @@ -7,6 +7,8 @@ icon="check" @click="resetCategories" /> +
+