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 @@
+
+
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 @@
+
+