diff --git a/backend/README.md b/backend/README.md index 3601dd2fa..0af58af48 100644 --- a/backend/README.md +++ b/backend/README.md @@ -64,6 +64,7 @@ your `.env` configuration file. Your backend is up and running at [http://localhost:4000/](http://localhost:4000/) This will start the GraphQL service \(by default on localhost:4000\) where you can issue GraphQL requests or access GraphQL Playground in the browser. +More details about our GraphQL playground and how to use it with ocelot.social can be found [here](./src/graphql/GraphQL-Playground.md).  diff --git a/backend/src/graphql/GraphQL-Playground.md b/backend/src/graphql/GraphQL-Playground.md new file mode 100644 index 000000000..af248f112 --- /dev/null +++ b/backend/src/graphql/GraphQL-Playground.md @@ -0,0 +1,108 @@ +# GraphQL Playground + +To use GraphQL Playground, we need to know some basics: + +## How To Login? + +First, we need to have a user from ocelot.social to log in as. +The user can be created by seeding the Neo4j database from the backend or by multiple GraphQL mutations. + +### Seed The Neo4j Database + +In your browser you can reach the GraphQL Playground under `http://localhost:4000/`, if the database and the backend are running, see [backend](../../README.md). +There you will also find instructions on how to seed the database. + +### Use GraphQL Mutations To Create A User + +TODO: Describe how to create a user using GraphQL mutations! + +### Login Via GraphQL + +You can register a user by sending the query: + +```gql +mutation { + login(email: "user@example.org", password: "1234") +} +``` + +Or use `"moderator@example.org"` or `"admin@example.org"` for the roll you need. + +If all goes well, you will receive a QGL response like: + +```json +{ + "data": { + "login": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU" + } +} +``` + +You can use this response to set an HTTP header when you click `HTTP HEADERS` in the footer. +Just set it with the login token you received in response: + +```json +{ + "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InUzIiwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTY2MjAyMzMwNSwiZXhwIjoxNzI1MTM4NTA1LCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.atBS-SOeS784HPeFl_5s8sRWehEAU1BkgcOZFD8d4bU" +} +``` + +This token is used for all other queries and mutations you send to the backend. + +## Query And Mutate + +When you are logged in and open a new playground tab by clicking "+", you can create a new group by sending the following mutation: + +```gql +mutation { + CreateGroup( + # id: "" + name: "My Group" + # slug: "" + about: "We will save the world" + description: "
English:
This group is hidden.
This group was created to allow investigative journalists to share and collaborate.
Here you can internally share posts and comments about them.
Deutsch:
Diese Gruppe ist verborgen.
Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.
Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.
" + groupType: hidden + actionRadius: interplanetary + categoryIds: ["cat12"] + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + } +} +``` + +You will receive the answer: + +```json +{ + "data": { + "CreateGroup": { + "id": "2e3bbadb-804b-4ebc-a673-2d7c7f05e827", + "name": "My Group", + "slug": "my-group", + "createdAt": "2022-09-01T09:44:47.969Z", + "updatedAt": "2022-09-01T09:44:47.969Z", + "disabled": false, + "deleted": false, + "about": "We will save the world", + "description": "English:
This group is hidden.
This group was created to allow investigative journalists to share and collaborate.
Here you can internally share posts and comments about them.
Deutsch:
Diese Gruppe ist verborgen.
Diese Gruppe wurde geschaffen, um investigativen Journalisten den Austausch und die Zusammenarbeit zu ermöglichen.
Hier könnt ihr euch intern über Beiträge und Kommentare zu ihnen austauschen.
", + "groupType": "hidden", + "actionRadius": "interplanetary", + "myRole": "owner" + } + } +} +``` + +If you look into the Neo4j database with your browser and search the groups, you will now also find your new group. +For more details about our Neo4j database read [here](../../../neo4j/README.md). 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/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 @@ + +