diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 2c8d7ff63..f4f8c654b 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -105,6 +105,7 @@ export default shield( blockedUsers: isAuthenticated, notifications: isAuthenticated, Donations: isAuthenticated, + userData: isAuthenticated, }, Mutation: { '*': deny, diff --git a/backend/src/schema/resolvers/userData.js b/backend/src/schema/resolvers/userData.js new file mode 100644 index 000000000..c73c46962 --- /dev/null +++ b/backend/src/schema/resolvers/userData.js @@ -0,0 +1,30 @@ +export default { + Query: { + userData: async (object, args, context, resolveInfo) => { + const id = context.user.id + const cypher = ` + MATCH (user:User { id: $id }) + WITH user + OPTIONAL MATCH (p:Post) + WHERE (p)<-[:COMMENTS]-(:Comment)<-[:WROTE]-(user) + OR (user)-[:WROTE]->(p) + RETURN { user: properties(user), posts: collect(properties(p)) } + AS result + ` + const session = context.driver.session() + const resultPromise = session.readTransaction(async (transaction) => { + const transactionResponse = transaction.run(cypher, { + id, + }) + return transactionResponse + }) + + try { + const result = await resultPromise + return result.records[0].get('result') + } finally { + session.close() + } + }, + }, +} diff --git a/backend/src/schema/resolvers/userData.spec.js b/backend/src/schema/resolvers/userData.spec.js new file mode 100644 index 000000000..db235f5e4 --- /dev/null +++ b/backend/src/schema/resolvers/userData.spec.js @@ -0,0 +1,211 @@ +import Factory, { cleanDatabase } from '../../db/factories' +import { gql } from '../../helpers/jest' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +let query, authenticatedUser + +const driver = getDriver() +const neode = getNeode() + +beforeAll(async () => { + await cleanDatabase() + const user = await Factory.build('user', { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }) + await Factory.build('user', { + id: 'o-user', + name: 'Unauthenticated User', + slug: 'unauthenticated-user', + }) + authenticatedUser = await user.toJson() + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query +}) + +afterAll(async () => { + await cleanDatabase() +}) + +const userDataQuery = gql` + query($id: ID!) { + userData(id: $id) { + user { + id + name + slug + } + posts { + id + title + content + comments { + content + author { + slug + } + } + } + } + } +` + +describe('resolvers/userData', () => { + let variables = { id: 'a-user' } + + describe('given one authenticated user who did not write anything so far', () => { + it("returns the user's data and no posts", async () => { + await expect(query({ query: userDataQuery, variables })).resolves.toMatchObject({ + data: { + userData: { + user: { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }, + posts: [], + }, + }, + }) + }) + + describe('the user writes a post', () => { + beforeAll(async () => { + await Factory.build( + 'post', + { + id: 'a-post', + title: 'A post', + content: 'A post', + }, + { authorId: 'a-user' }, + ) + }) + + it("returns the user's data and the post", async () => { + await expect(query({ query: userDataQuery, variables })).resolves.toMatchObject({ + data: { + userData: { + user: { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }, + posts: [ + { + id: 'a-post', + title: 'A post', + content: 'A post', + }, + ], + }, + }, + }) + }) + + describe('the user comments another post', () => { + beforeAll(async () => { + await Factory.build( + 'post', + { + id: 'b-post', + title: 'B post', + content: 'B post', + }, + { authorId: 'o-user' }, + ) + await Factory.build( + 'comment', + { + content: 'A comment to post B', + }, + { + postId: 'b-post', + authorId: 'a-user', + }, + ) + }) + + it('returns the written post and the commented post', async () => { + await expect(query({ query: userDataQuery, variables })).resolves.toMatchObject({ + data: { + userData: { + user: { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }, + posts: expect.arrayContaining([ + { + id: 'a-post', + title: 'A post', + content: 'A post', + comments: [], + }, + { + id: 'b-post', + title: 'B post', + content: 'B post', + comments: [ + { + content: 'A comment to post B', + author: { slug: 'john-doe' }, + }, + ], + }, + ]), + }, + }, + }) + }) + }) + }) + }) + + describe('try to request data of another user', () => { + variables = { id: 'o-user' } + it('returns the data of the authenticated user', async () => { + await expect(query({ query: userDataQuery, variables })).resolves.toMatchObject({ + data: { + userData: { + user: { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }, + posts: expect.arrayContaining([ + { + id: 'a-post', + title: 'A post', + content: 'A post', + comments: [], + }, + { + id: 'b-post', + title: 'B post', + content: 'B post', + comments: [ + { + content: 'A comment to post B', + author: { slug: 'john-doe' }, + }, + ], + }, + ]), + }, + }, + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/UserData.gql b/backend/src/schema/types/type/UserData.gql new file mode 100644 index 000000000..60ad5c12f --- /dev/null +++ b/backend/src/schema/types/type/UserData.gql @@ -0,0 +1,10 @@ +type UserData { + user: User! + posts: [Post] +} + +type Query { + userData( + id: ID + ): UserData +} diff --git a/webapp/assets/_new/icons/svgs/download.svg b/webapp/assets/_new/icons/svgs/download.svg new file mode 100644 index 000000000..b988fa171 --- /dev/null +++ b/webapp/assets/_new/icons/svgs/download.svg @@ -0,0 +1,5 @@ + + +download + + diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 3b015dacc..4b3a67775 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -292,3 +292,32 @@ export const currentUserCountQuery = () => gql` } } ` + +export const userDataQuery = (i18n) => { + return gql` + ${userFragment} + ${postFragment} + ${commentFragment} + query($id: ID!) { + userData(id: $id) { + user { + ...user + } + posts { + ...post + categories { + id + name + } + comments { + author { + id + slug + } + ...comment + } + } + } + } + ` +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index adee8921c..cff6af3f7 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -648,6 +648,8 @@ "success": "Konto erfolgreich gelöscht!" }, "download": { + "description": "Klicke auf den Knopf oben, um den Inhalt deiner Beiträge und Kommentare herunterzuladen. Um die Bilder der Beiträge herunterzuladen, musst du auf den jeweiligen Link unten klicken.", + "json": "als JSON", "name": "Daten herunterladen" }, "email": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 8959e3830..bbb779d2d 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -648,6 +648,8 @@ "success": "Account successfully deleted!" }, "download": { + "description": "Click on the button above to download the content of your posts and comments. To download the images of your posts, you have to click on the corresponding link below.", + "json": "as JSON", "name": "Download Data" }, "email": { diff --git a/webapp/pages/settings.vue b/webapp/pages/settings.vue index 950652028..6bd78b701 100644 --- a/webapp/pages/settings.vue +++ b/webapp/pages/settings.vue @@ -51,32 +51,30 @@ export default { name: this.$t('settings.embeds.name'), path: `/settings/embeds`, }, + { + name: this.$t('settings.download.name'), + path: `/settings/data-download`, + }, { name: this.$t('settings.deleteUserAccount.name'), path: `/settings/delete-account`, }, // TODO implement /* { - name: this.$t('settings.invites.name'), - path: `/settings/invites` - }, */ + name: this.$t('settings.invites.name'), + path: `/settings/invites` + }, */ // TODO implement /* { - name: this.$t('settings.download.name'), - path: `/settings/data-download` - }, */ - // TODO implement + name: this.$t('settings.organizations.name'), + path: `/settings/my-organizations` + }, */ // TODO implement /* { - name: this.$t('settings.organizations.name'), - path: `/settings/my-organizations` - }, */ - // TODO implement - /* { - name: this.$t('settings.languages.name'), - path: `/settings/languages` - }, - } */ + name: this.$t('settings.languages.name'), + path: `/settings/languages` + }, + } */ ] }, }, diff --git a/webapp/pages/settings/data-download.vue b/webapp/pages/settings/data-download.vue index b7951182d..9cf1b1f8e 100644 --- a/webapp/pages/settings/data-download.vue +++ b/webapp/pages/settings/data-download.vue @@ -1,16 +1,89 @@