diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index f6f675008..e588e1b62 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -323,6 +323,7 @@ export default shield( GenerateInviteCode: isAuthenticated, switchUserRole: isAdmin, markTeaserAsViewed: allow, + saveCategorySettings: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 9aac2ab6a..239a299dd 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -18,19 +18,25 @@ export default { if (isMember === true) { groupCypher = ` MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupIdCypher}) + 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} ` } else { if (isMember === false) { groupCypher = ` MATCH (group:Group${groupIdCypher}) - WHERE NOT (:User {id: $userId})-[:MEMBER_OF]->(group) + WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group)) + WITH group + WHERE group.groupType IN ['public', 'closed'] RETURN group {.*, myRole: NULL} ` } else { groupCypher = ` MATCH (group:Group${groupIdCypher}) 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']) RETURN group {.*, myRole: membership.role} ` } diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 2ca40b3e7..e9b38cc2b 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -267,6 +267,7 @@ describe('in mode', () => { describe('authenticated', () => { let otherUser + let ownerOfHiddenGroupUser beforeAll(async () => { otherUser = await Factory.build( @@ -276,7 +277,18 @@ describe('in mode', () => { name: 'Other TestUser', }, { - email: 'test2@example.org', + email: 'other-user@example.org', + password: '1234', + }, + ) + ownerOfHiddenGroupUser = await Factory.build( + 'user', + { + id: 'owner-of-hidden-group', + name: 'Owner Of Hidden Group', + }, + { + email: 'owner-of-hidden-group@example.org', password: '1234', }, ) @@ -293,6 +305,59 @@ describe('in mode', () => { categoryIds, }, }) + authenticatedUser = await ownerOfHiddenGroupUser.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, + }, + }) + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'second-hidden-group', + name: 'Second Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'second-hidden-group', + userId: 'current-user', + roleInGroup: 'pending', + }, + }) + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'third-hidden-group', + name: 'Third Investigative Journalism Group', + about: 'We will change all.', + description: 'We research …' + descriptionAdditional100, + groupType: 'hidden', + actionRadius: 'global', + categoryIds, + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'third-hidden-group', + userId: 'current-user', + roleInGroup: 'usual', + }, + }) authenticatedUser = await user.toJson() await mutate({ mutation: createGroupMutation, @@ -309,117 +374,175 @@ describe('in mode', () => { }) describe('query groups', () => { - describe('without any filters', () => { - it('finds all groups', async () => { - await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({ - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }), - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, - }) - }) - - describe('categories', () => { - beforeEach(() => { - CONFIG.CATEGORIES_ACTIVE = true - }) - - it('has set categories', async () => { - await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({ + describe('in general finds only listed groups – no hidden groups where user is none or pending member', () => { + describe('without any filters', () => { + it('finds all listed groups', async () => { + const result = await query({ query: groupQuery, variables: {} }) + expect(result).toMatchObject({ data: { Group: expect.arrayContaining([ expect.objectContaining({ id: 'my-group', slug: 'the-best-group', - categories: expect.arrayContaining([ - expect.objectContaining({ id: 'cat4' }), - expect.objectContaining({ id: 'cat9' }), - expect.objectContaining({ id: 'cat15' }), - ]), myRole: 'owner', }), expect.objectContaining({ id: 'others-group', slug: 'uninteresting-group', - categories: expect.arrayContaining([ - expect.objectContaining({ id: 'cat4' }), - expect.objectContaining({ id: 'cat9' }), - expect.objectContaining({ id: 'cat15' }), - ]), + myRole: null, + }), + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(3) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + it('has set categories', async () => { + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + categories: expect.arrayContaining([ + expect.objectContaining({ id: 'cat4' }), + expect.objectContaining({ id: 'cat9' }), + expect.objectContaining({ id: 'cat15' }), + ]), + myRole: null, + }), + ]), + }, + errors: undefined, + }) + }) + }) + }) + + describe('with given id', () => { + describe("id = 'my-group'", () => { + it('finds only the listed group with this id', async () => { + const result = await query({ query: groupQuery, variables: { id: 'my-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("id = 'third-hidden-group'", () => { + it("finds only the hidden group where I'm 'usual' member", async () => { + const result = await query({ + query: groupQuery, + variables: { id: 'third-hidden-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("id = 'second-hidden-group'", () => { + it("finds no hidden group where I'm 'pending' member", async () => { + const result = await query({ + query: groupQuery, + variables: { id: 'second-hidden-group' }, + }) + expect(result.data.Group.length).toBe(0) + }) + }) + + describe("id = 'hidden-group'", () => { + it("finds no hidden group where I'm not(!) a member at all", async () => { + const result = await query({ + query: groupQuery, + variables: { id: 'hidden-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 } }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'third-hidden-group', + slug: 'third-investigative-journalism-group', + myRole: 'usual', + }), + ]), + }, + errors: undefined, + }) + expect(result.data.Group.length).toBe(2) + }) + }) + + describe('isMember = false', () => { + it('finds only listed groups where user is not(!) member', async () => { + const result = await query({ query: groupQuery, variables: { isMember: false } }) + expect(result).toMatchObject({ + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', myRole: null, }), ]), }, errors: undefined, }) - }) - }) - }) - - describe("id = 'my-group'", () => { - it('finds only the group with this id', async () => { - await expect( - query({ query: groupQuery, variables: { id: 'my-group' } }), - ).resolves.toMatchObject({ - data: { - Group: [ - expect.objectContaining({ - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }), - ], - }, - errors: undefined, - }) - }) - }) - - describe('isMember = true', () => { - it('finds only groups where user is member', async () => { - await expect( - query({ query: groupQuery, variables: { isMember: true } }), - ).resolves.toMatchObject({ - data: { - Group: [ - { - id: 'my-group', - slug: 'the-best-group', - myRole: 'owner', - }, - ], - }, - errors: undefined, - }) - }) - }) - - describe('isMember = false', () => { - it('finds only groups where user is not(!) member', async () => { - await expect( - query({ query: groupQuery, variables: { isMember: false } }), - ).resolves.toMatchObject({ - data: { - Group: expect.arrayContaining([ - expect.objectContaining({ - id: 'others-group', - slug: 'uninteresting-group', - myRole: null, - }), - ]), - }, - errors: undefined, + expect(result.data.Group.length).toBe(1) }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 5dc78c5e1..23a39a2a1 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -269,6 +269,45 @@ export default { session.close() } }, + saveCategorySettings: async (object, args, context, resolveInfo) => { + const { activeCategories } = args + const { + user: { id }, + } = context + + const session = context.driver.session() + await session.writeTransaction((transaction) => { + return transaction.run( + ` + MATCH (user:User { id: $id })-[previousCategories:NOT_INTERESTED_IN]->(category:Category) + DELETE previousCategories + RETURN user, category + `, + { id }, + ) + }) + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const saveCategorySettingsResponse = await transaction.run( + ` + MATCH (category:Category) WHERE NOT category.id IN $activeCategories + MATCH (user:User { id: $id }) + MERGE (user)-[r:NOT_INTERESTED_IN]->(category) + RETURN user, r, category + `, + { id, activeCategories }, + ) + const [user] = await saveCategorySettingsResponse.records.map((record) => + record.get('user'), + ) + return user + }) + try { + await writeTxResultPromise + return true + } finally { + session.close() + } + }, }, User: { email: async (parent, params, context, resolveInfo) => { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 920ef52ea..7116d2201 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -3,6 +3,7 @@ import { gql } from '../../helpers/jest' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' +import { categories } from '../../constants/categories' const categoryIds = ['cat9'] let user @@ -56,6 +57,12 @@ const switchUserRoleMutation = gql` } ` +const saveCategorySettings = gql` + mutation ($activeCategories: [String]) { + saveCategorySettings(activeCategories: $activeCategories) + } +` + beforeAll(async () => { await cleanDatabase() @@ -544,3 +551,146 @@ describe('switch user role', () => { }) }) }) + +describe('save category settings', () => { + beforeEach(async () => { + await Promise.all( + categories.map(({ icon, name }, index) => { + Factory.build('category', { + id: `cat${index + 1}`, + slug: name, + name, + icon, + }) + }), + ) + }) + + beforeEach(async () => { + user = await Factory.build('user', { + id: 'user', + role: 'user', + }) + variables = { + activeCategories: ['cat1', 'cat3', 'cat5'], + } + }) + + describe('not authenticated', () => { + beforeEach(async () => { + authenticatedUser = undefined + }) + + it('throws an error', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ], + }), + ) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + const userQuery = gql` + query ($id: ID) { + User(id: $id) { + activeCategories { + id + } + } + } + ` + + describe('no categories saved', () => { + it('returns true for active categories mutation', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + data: { saveCategorySettings: true }, + }), + ) + }) + + describe('query for user', () => { + beforeEach(async () => { + await mutate({ mutation: saveCategorySettings, variables }) + }) + + it('returns the active categories when user is queried', async () => { + await expect( + query({ query: userQuery, variables: { id: authenticatedUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + User: [ + { + activeCategories: expect.arrayContaining([ + { id: 'cat1' }, + { id: 'cat3' }, + { id: 'cat5' }, + ]), + }, + ], + }, + }), + ) + }) + }) + }) + + describe('categories already saved', () => { + beforeEach(async () => { + variables = { + activeCategories: ['cat1', 'cat3', 'cat5'], + } + await mutate({ mutation: saveCategorySettings, variables }) + variables = { + activeCategories: ['cat10', 'cat11', 'cat12', 'cat8', 'cat9'], + } + }) + + it('returns true', async () => { + await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual( + expect.objectContaining({ + data: { saveCategorySettings: true }, + }), + ) + }) + + describe('query for user', () => { + beforeEach(async () => { + await mutate({ mutation: saveCategorySettings, variables }) + }) + + it('returns the new active categories when user is queried', async () => { + await expect( + query({ query: userQuery, variables: { id: authenticatedUser.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + User: [ + { + activeCategories: expect.arrayContaining([ + { id: 'cat10' }, + { id: 'cat11' }, + { id: 'cat12' }, + { id: 'cat8' }, + { id: 'cat9' }, + ]), + }, + ], + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 4219cd00e..3d71aac78 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -115,6 +115,14 @@ type User { emotions: [EMOTED] + activeCategories: [Category] @cypher( + statement: """ + MATCH (category:Category) + WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category)) + RETURN category + """ + ) + myRoleInGroup: GroupMemberRole } @@ -222,4 +230,6 @@ type Mutation { unblockUser(id: ID!): User switchUserRole(role: UserRole!, id: ID!): User + + saveCategorySettings(activeCategories: [String]): Boolean } diff --git a/webapp/components/FilterMenu/CategoriesMenu.vue b/webapp/components/FilterMenu/CategoriesMenu.vue new file mode 100644 index 000000000..37bd19c84 --- /dev/null +++ b/webapp/components/FilterMenu/CategoriesMenu.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue index 84c7c1f67..9e211ccf9 100644 --- a/webapp/components/FilterMenu/FilterMenu.vue +++ b/webapp/components/FilterMenu/FilterMenu.vue @@ -14,7 +14,6 @@

{{ $t('filter-menu.filter-by') }}

-

{{ $t('filter-menu.order-by') }}

@@ -29,24 +28,17 @@ import Dropdown from '~/components/Dropdown' import { mapGetters } from 'vuex' import FollowingFilter from './FollowingFilter' import OrderByFilter from './OrderByFilter' -import CategoriesFilter from './CategoriesFilter' export default { components: { Dropdown, FollowingFilter, - CategoriesFilter, OrderByFilter, }, props: { placement: { type: String }, offset: { type: [String, Number] }, }, - data() { - return { - categoriesActive: this.$env.CATEGORIES_ACTIVE, - } - }, computed: { ...mapGetters({ filterActive: 'posts/isActive', diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue index 0dfd752bf..1e4fe5cf5 100644 --- a/webapp/layouts/default.vue +++ b/webapp/layouts/default.vue @@ -15,6 +15,15 @@ > + + + + +