diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index f3c8ca65e..8e4569a52 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -112,7 +112,7 @@ export default shield( CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, - report: isAuthenticated, + fileReport: isAuthenticated, CreateSocialMedia: isAuthenticated, UpdateSocialMedia: isMySocialMedia, DeleteSocialMedia: isMySocialMedia, @@ -125,8 +125,7 @@ export default shield( shout: isAuthenticated, unshout: isAuthenticated, changePassword: isAuthenticated, - enable: isModerator, - disable: isModerator, + review: isModerator, CreateComment: isAuthenticated, UpdateComment: isAuthor, DeleteComment: isAuthor, diff --git a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js index 6da080ebb..1c97cb874 100644 --- a/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js +++ b/backend/src/middleware/softDelete/softDeleteMiddleware.spec.js @@ -8,14 +8,8 @@ const factory = Factory() const neode = getNeode() const driver = getDriver() -let query -let mutate -let graphqlQuery const categoryIds = ['cat9'] -let authenticatedUser -let user -let moderator -let troll +let query, graphqlQuery, authenticatedUser, user, moderator, troll const action = () => { return query({ query: graphqlQuery }) @@ -38,18 +32,17 @@ beforeAll(async () => { avatar: '/some/offensive/avatar.jpg', about: 'This self description is very offensive', }), + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), ]) user = users[0] moderator = users[1] troll = users[2] - await neode.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) - await Promise.all([ user.relateTo(troll, 'following'), factory.create('Post', { @@ -70,33 +63,32 @@ beforeAll(async () => { }), ]) - await Promise.all([ + const resources = await Promise.all([ factory.create('Comment', { author: user, id: 'c2', postId: 'p3', content: 'Enabled comment on public post', }), + factory.create('Post', { + id: 'p2', + author: troll, + title: 'Disabled post', + content: 'This is an offensive post content', + contentExcerpt: 'This is an offensive post content', + image: '/some/offensive/image.jpg', + deleted: false, + categoryIds, + }), + factory.create('Comment', { + id: 'c1', + author: troll, + postId: 'p3', + content: 'Disabled comment', + contentExcerpt: 'Disabled comment', + }), ]) - await factory.create('Post', { - id: 'p2', - author: troll, - title: 'Disabled post', - content: 'This is an offensive post content', - contentExcerpt: 'This is an offensive post content', - image: '/some/offensive/image.jpg', - deleted: false, - categoryIds, - }) - await factory.create('Comment', { - id: 'c1', - author: troll, - postId: 'p3', - content: 'Disabled comment', - contentExcerpt: 'Disabled comment', - }) - const { server } = createServer({ context: () => { return { @@ -108,20 +100,57 @@ beforeAll(async () => { }) const client = createTestClient(server) query = client.query - mutate = client.mutate - authenticatedUser = await moderator.toJson() - const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } - ` - await Promise.all([ - mutate({ mutation: disableMutation, variables: { id: 'c1' } }), - mutate({ mutation: disableMutation, variables: { id: 'u2' } }), - mutate({ mutation: disableMutation, variables: { id: 'p2' } }), + const trollingPost = resources[1] + const trollingComment = resources[2] + + const reports = await Promise.all([ + factory.create('Report'), + factory.create('Report'), + factory.create('Report'), + ]) + const reportAgainstTroll = reports[0] + const reportAgainstTrollingPost = reports[1] + const reportAgainstTrollingComment = reports[2] + + const reportVariables = { + resourceId: 'undefined-resource', + reasonCategory: 'discrimination_etc', + reasonDescription: 'I am what I am !!!', + } + + await Promise.all([ + reportAgainstTroll.relateTo(user, 'filed', { ...reportVariables, resourceId: 'u2' }), + reportAgainstTroll.relateTo(troll, 'belongsTo'), + reportAgainstTrollingPost.relateTo(user, 'filed', { ...reportVariables, resourceId: 'p2' }), + reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'), + reportAgainstTrollingComment.relateTo(moderator, 'filed', { + ...reportVariables, + resourceId: 'c1', + }), + reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'), + ]) + + const disableVariables = { + resourceId: 'undefined-resource', + disable: true, + closed: false, + } + + await Promise.all([ + reportAgainstTroll.relateTo(moderator, 'reviewed', { ...disableVariables, resourceId: 'u2' }), + troll.update({ disabled: true, updatedAt: new Date().toISOString() }), + reportAgainstTrollingPost.relateTo(moderator, 'reviewed', { + ...disableVariables, + resourceId: 'p2', + }), + trollingPost.update({ disabled: true, updatedAt: new Date().toISOString() }), + reportAgainstTrollingComment.relateTo(moderator, 'reviewed', { + ...disableVariables, + resourceId: 'c1', + }), + trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }), ]) - authenticatedUser = null }) afterAll(async () => { diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index 4954a5584..f36458e61 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -61,31 +61,58 @@ const validateUpdatePost = async (resolve, root, args, context, info) => { const validateReport = async (resolve, root, args, context, info) => { const { resourceId } = args - const { user, driver } = context + const { user } = context if (resourceId === user.id) throw new Error('You cannot report yourself!') + return resolve(root, args, context, info) +} + +const validateReview = async (resolve, root, args, context, info) => { + const { resourceId } = args + let existingReportedResource + const { user, driver } = context + if (resourceId === user.id) throw new Error('You cannot review yourself!') const session = driver.session() - try { - const reportQueryRes = await session.run( + const reportReadTxPromise = session.writeTransaction(async txc => { + const validateReviewTransactionResponse = await txc.run( ` - MATCH (:User {id:$submitterId})-[:REPORTED]->(resource {id:$resourceId}) - RETURN labels(resource)[0] as label - `, + MATCH (resource {id: $resourceId}) + WHERE resource:User OR resource:Post OR resource:Comment + OPTIONAL MATCH (:User)-[filed:FILED]->(:Report {closed: false})-[:BELONGS_TO]->(resource) + OPTIONAL MATCH (resource)<-[:WROTE]-(author:User) + RETURN labels(resource)[0] AS label, author, filed + `, { resourceId, submitterId: user.id, }, ) - const [existingReportedResource] = reportQueryRes.records.map(record => { - return { - label: record.get('label'), - } - }) - - if (existingReportedResource) throw new Error(`${existingReportedResource.label}`) - return resolve(root, args, context, info) + return validateReviewTransactionResponse.records.map(record => ({ + label: record.get('label'), + author: record.get('author'), + filed: record.get('filed'), + })) + }) + try { + const txResult = await reportReadTxPromise + existingReportedResource = txResult + if (!existingReportedResource || !existingReportedResource.length) + throw new Error(`Resource not found or is not a Post|Comment|User!`) + existingReportedResource = existingReportedResource[0] + if (!existingReportedResource.filed) + throw new Error( + `Before starting the review process, please report the ${existingReportedResource.label}!`, + ) + const authorId = + existingReportedResource.label !== 'User' && existingReportedResource.author + ? existingReportedResource.author.properties.id + : null + if (authorId && authorId === user.id) + throw new Error(`You cannot review your own ${existingReportedResource.label}!`) } finally { session.close() } + + return resolve(root, args, context, info) } export default { @@ -94,6 +121,7 @@ export default { UpdateComment: validateUpdateComment, CreatePost: validatePost, UpdatePost: validateUpdatePost, - report: validateReport, + fileReport: validateReport, + review: validateReview, }, } diff --git a/backend/src/middleware/validation/validationMiddleware.spec.js b/backend/src/middleware/validation/validationMiddleware.spec.js index ec5f3e012..97bb6254b 100644 --- a/backend/src/middleware/validation/validationMiddleware.spec.js +++ b/backend/src/middleware/validation/validationMiddleware.spec.js @@ -7,7 +7,16 @@ import createServer from '../../server' const factory = Factory() const neode = getNeode() const driver = getDriver() -let mutate, authenticatedUser, user +let authenticatedUser, + mutate, + users, + offensivePost, + reportVariables, + disableVariables, + reportingUser, + moderatingUser, + commentingUser + const createCommentMutation = gql` mutation($id: ID, $postId: ID!, $content: String!) { CreateComment(id: $id, postId: $postId, content: $content) { @@ -23,8 +32,14 @@ const updateCommentMutation = gql` } ` const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { + CreatePost( + id: $id + title: $title + content: $content + language: $language + categoryIds: $categoryIds + ) { id } } @@ -37,7 +52,25 @@ const updatePostMutation = gql` } } ` - +const reportMutation = gql` + mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { + fileReport( + resourceId: $resourceId + reasonCategory: $reasonCategory + reasonDescription: $reasonDescription + ) { + id + } + } +` +const reviewMutation = gql` + mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) { + review(resourceId: $resourceId, disable: $disable, closed: $closed) { + createdAt + updatedAt + } + } +` beforeAll(() => { const { server } = createServer({ context: () => { @@ -52,13 +85,42 @@ beforeAll(() => { }) beforeEach(async () => { - user = await factory.create('User', { - id: 'user-id', - }) - await factory.create('Post', { - id: 'post-4-commenting', - authorId: 'user-id', - }) + users = await Promise.all([ + factory.create('User', { + id: 'reporting-user', + }), + factory.create('User', { + id: 'moderating-user', + role: 'moderator', + }), + factory.create('User', { + id: 'commenting-user', + }), + ]) + reportVariables = { + resourceId: 'whatever', + reasonCategory: 'other', + reasonDescription: 'Violates code of conduct !!!', + } + disableVariables = { + resourceId: 'undefined-resource', + disable: true, + closed: false, + } + reportingUser = users[0] + moderatingUser = users[1] + commentingUser = users[2] + const posts = await Promise.all([ + factory.create('Post', { + id: 'offensive-post', + authorId: 'moderating-user', + }), + factory.create('Post', { + id: 'post-4-commenting', + authorId: 'commenting-user', + }), + ]) + offensivePost = posts[0] }) afterEach(async () => { @@ -72,7 +134,7 @@ describe('validateCreateComment', () => { postId: 'whatever', content: '', } - authenticatedUser = await user.toJson() + authenticatedUser = await commentingUser.toJson() }) it('throws an error if content is empty', async () => { @@ -114,13 +176,13 @@ describe('validateCreateComment', () => { beforeEach(async () => { await factory.create('Comment', { id: 'comment-id', - authorId: 'user-id', + authorId: 'commenting-user', }) updateCommentVariables = { id: 'whatever', content: '', } - authenticatedUser = await user.toJson() + authenticatedUser = await commentingUser.toJson() }) it('throws an error if content is empty', async () => { @@ -151,7 +213,7 @@ describe('validateCreateComment', () => { title: 'I am a title', content: 'Some content', } - authenticatedUser = await user.toJson() + authenticatedUser = await commentingUser.toJson() }) describe('categories', () => { @@ -242,3 +304,97 @@ describe('validateCreateComment', () => { }) }) }) + +describe('validateReport', () => { + it('throws an error if a user tries to report themself', async () => { + authenticatedUser = await reportingUser.toJson() + reportVariables = { ...reportVariables, resourceId: 'reporting-user' } + await expect( + mutate({ mutation: reportMutation, variables: reportVariables }), + ).resolves.toMatchObject({ + data: { fileReport: null }, + errors: [{ message: 'You cannot report yourself!' }], + }) + }) +}) + +describe('validateReview', () => { + beforeEach(async () => { + const reportAgainstModerator = await factory.create('Report') + await Promise.all([ + reportAgainstModerator.relateTo(reportingUser, 'filed', { + ...reportVariables, + resourceId: 'moderating-user', + }), + reportAgainstModerator.relateTo(moderatingUser, 'belongsTo'), + ]) + authenticatedUser = await moderatingUser.toJson() + }) + + it('throws an error if a user tries to review a report against them', async () => { + disableVariables = { ...disableVariables, resourceId: 'moderating-user' } + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: null }, + errors: [{ message: 'You cannot review yourself!' }], + }) + }) + + it('throws an error for invaild resource', async () => { + disableVariables = { ...disableVariables, resourceId: 'non-existent-resource' } + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: null }, + errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }], + }) + }) + + it('throws an error if no report exists', async () => { + disableVariables = { ...disableVariables, resourceId: 'offensive-post' } + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: null }, + errors: [{ message: 'Before starting the review process, please report the Post!' }], + }) + }) + + it('throws an error if a moderator tries to review their own resource(Post|Comment)', async () => { + const reportAgainstOffensivePost = await factory.create('Report') + await Promise.all([ + reportAgainstOffensivePost.relateTo(reportingUser, 'filed', { + ...reportVariables, + resourceId: 'offensive-post', + }), + reportAgainstOffensivePost.relateTo(offensivePost, 'belongsTo'), + ]) + disableVariables = { ...disableVariables, resourceId: 'offensive-post' } + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: null }, + errors: [{ message: 'You cannot review your own Post!' }], + }) + }) + + describe('moderate a resource that is not a (Comment|Post|User) ', () => { + beforeEach(async () => { + await Promise.all([factory.create('Tag', { id: 'tag-id' })]) + }) + + it('returns null', async () => { + disableVariables = { + ...disableVariables, + resourceId: 'tag-id', + } + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: null }, + errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }], + }) + }) + }) +}) diff --git a/backend/src/models/Comment.js b/backend/src/models/Comment.js index c89103e5d..54cbda675 100644 --- a/backend/src/models/Comment.js +++ b/backend/src/models/Comment.js @@ -25,12 +25,6 @@ module.exports = { target: 'User', direction: 'in', }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, notified: { type: 'relationship', relationship: 'NOTIFIED', diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index 5ac8378c2..59fd31f35 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -17,12 +17,6 @@ module.exports = { image: { type: 'string', allow: [null] }, deleted: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, notified: { type: 'relationship', relationship: 'NOTIFIED', diff --git a/backend/src/models/Report.js b/backend/src/models/Report.js new file mode 100644 index 000000000..b66aa4076 --- /dev/null +++ b/backend/src/models/Report.js @@ -0,0 +1,53 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + rule: { type: 'string', default: 'latestReviewUpdatedAtRules' }, + disable: { type: 'boolean', default: false }, + closed: { type: 'boolean', default: false }, + belongsTo: { + type: 'relationship', + relationship: 'BELONGS_TO', + target: ['User', 'Comment', 'Post'], + direction: 'out', + }, + filed: { + type: 'relationship', + relationship: 'FILED', + target: 'User', + direction: 'in', + properties: { + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + resourceId: { type: 'string', primary: true, default: uuid }, + reasonCategory: { + type: 'string', + valid: [ + 'other', + 'discrimination_etc', + 'pornographic_content_links', + 'glorific_trivia_of_cruel_inhuman_acts', + 'doxing', + 'intentional_intimidation_stalking_persecution', + 'advert_products_services_commercial', + 'criminal_behavior_violation_german_law', + ], + invalid: [null], + }, + reasonDescription: { type: 'string', allow: [null] }, + }, + }, + reviewed: { + type: 'relationship', + relationship: 'REVIEWED', + target: 'User', + direction: 'in', + properties: { + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + disable: { type: 'boolean', default: false }, + closed: { type: 'boolean', default: false }, + }, + }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index fd6e88c27..32f053e2b 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -42,12 +42,6 @@ module.exports = { }, }, friends: { type: 'relationship', relationship: 'FRIENDS', target: 'User', direction: 'both' }, - disabledBy: { - type: 'relationship', - relationship: 'DISABLED', - target: 'User', - direction: 'in', - }, rewarded: { type: 'relationship', relationship: 'REWARDED', diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 239076adc..0b9378162 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -12,4 +12,5 @@ export default { Tag: require('./Tag.js'), Location: require('./Location.js'), Donations: require('./Donations.js'), + Report: require('./Report.js'), } diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index 4252bd817..274697238 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -17,7 +17,9 @@ export default makeAugmentedSchema({ 'Location', 'SocialMedia', 'NOTIFIED', - 'REPORTED', + 'FILED', + 'REVIEWED', + 'Report', 'Donations', ], }, diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index 20869a73a..97b461511 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -78,7 +78,6 @@ export default { hasOne: { author: '<-[:WROTE]-(related:User)', post: '-[:COMMENTS]->(related:Post)', - disabledBy: '<-[:DISABLED]-(related:User)', }, }), }, diff --git a/backend/src/schema/resolvers/moderation.js b/backend/src/schema/resolvers/moderation.js index de756b7b2..4bdf82d50 100644 --- a/backend/src/schema/resolvers/moderation.js +++ b/backend/src/schema/resolvers/moderation.js @@ -1,47 +1,51 @@ +const transformReturnType = record => { + return { + ...record.get('review').properties, + report: record.get('report').properties, + resource: { + __typename: record.get('type'), + ...record.get('resource').properties, + }, + } +} + export default { Mutation: { - disable: async (object, params, { user, driver }) => { - const { id } = params - const { id: userId } = user - const cypher = ` - MATCH (u:User {id: $userId}) - MATCH (resource {id: $id}) - WHERE resource:User OR resource:Comment OR resource:Post - SET resource.disabled = true - MERGE (resource)<-[:DISABLED]-(u) - RETURN resource {.id} - ` + review: async (_object, params, context, _resolveInfo) => { + const { user: moderator, driver } = context + + let createdRelationshipWithNestedAttributes = null // return value const session = driver.session() try { - const res = await session.run(cypher, { id, userId }) - const [resource] = res.records.map(record => { - return record.get('resource') + const cypher = ` + MATCH (moderator:User {id: $moderatorId}) + MATCH (resource {id: $params.resourceId})<-[:BELONGS_TO]-(report:Report {closed: false}) + WHERE resource:User OR resource:Post OR resource:Comment + MERGE (report)<-[review:REVIEWED]-(moderator) + ON CREATE SET review.createdAt = $dateTime, review.updatedAt = review.createdAt + ON MATCH SET review.updatedAt = $dateTime + SET review.disable = $params.disable + SET report.updatedAt = $dateTime, report.closed = $params.closed + SET resource.disabled = review.disable + + RETURN review, report, resource, labels(resource)[0] AS type + ` + const reviewWriteTxResultPromise = session.writeTransaction(async txc => { + const reviewTransactionResponse = await txc.run(cypher, { + params, + moderatorId: moderator.id, + dateTime: new Date().toISOString(), + }) + return reviewTransactionResponse.records.map(transformReturnType) }) - if (!resource) return null - return resource.id - } finally { - session.close() - } - }, - enable: async (object, params, { user, driver }) => { - const { id } = params - const cypher = ` - MATCH (resource {id: $id})<-[d:DISABLED]-() - SET resource.disabled = false - DELETE d - RETURN resource {.id} - ` - const session = driver.session() - try { - const res = await session.run(cypher, { id }) - const [resource] = res.records.map(record => { - return record.get('resource') - }) - if (!resource) return null - return resource.id + const txResult = await reviewWriteTxResultPromise + if (!txResult[0]) return null + createdRelationshipWithNestedAttributes = txResult[0] } finally { session.close() } + + return createdRelationshipWithNestedAttributes }, }, } diff --git a/backend/src/schema/resolvers/moderation.spec.js b/backend/src/schema/resolvers/moderation.spec.js index 765126c52..5e280a6f5 100644 --- a/backend/src/schema/resolvers/moderation.spec.js +++ b/backend/src/schema/resolvers/moderation.spec.js @@ -8,45 +8,53 @@ const factory = Factory() const neode = getNeode() const driver = getDriver() -let query, mutate, authenticatedUser, variables, moderator, nonModerator +let mutate, + authenticatedUser, + disableVariables, + enableVariables, + moderator, + nonModerator, + closeReportVariables -const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } -` -const enableMutation = gql` - mutation($id: ID!) { - enable(id: $id) - } -` - -const commentQuery = gql` - query($id: ID!) { - Comment(id: $id) { - id - disabled - disabledBy { - id +const reviewMutation = gql` + mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) { + review(resourceId: $resourceId, disable: $disable, closed: $closed) { + createdAt + updatedAt + resource { + __typename + ... on User { + id + disabled + } + ... on Post { + id + disabled + } + ... on Comment { + id + disabled + } } - } - } -` - -const postQuery = gql` - query($id: ID) { - Post(id: $id) { - id - disabled - disabledBy { + report { id + createdAt + updatedAt + closed + reviewed { + createdAt + moderator { + id + } + } } } } ` describe('moderate resources', () => { - beforeAll(() => { + beforeAll(async () => { + await factory.cleanDatabase() authenticatedUser = undefined const { server } = createServer({ context: () => { @@ -58,11 +66,19 @@ describe('moderate resources', () => { }, }) mutate = createTestClient(server).mutate - query = createTestClient(server).query }) beforeEach(async () => { - variables = {} + disableVariables = { + resourceId: 'undefined-resource', + disable: true, + closed: false, + } + enableVariables = { + resourceId: 'undefined-resource', + disable: false, + closed: false, + } authenticatedUser = null moderator = await factory.create('User', { id: 'moderator-id', @@ -71,155 +87,392 @@ describe('moderate resources', () => { password: '1234', role: 'moderator', }) + nonModerator = await factory.create('User', { + id: 'non-moderator', + name: 'Non Moderator', + email: 'non.moderator@example.org', + password: '1234', + }) }) afterEach(async () => { await factory.cleanDatabase() }) - describe('disable', () => { - beforeEach(() => { - variables = { - id: 'some-resource', - } - }) + describe('review to close report, leaving resource enabled', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], }) }) }) + describe('authenticated', () => { - describe('non moderator', () => { - beforeEach(async () => { - nonModerator = await factory.create('User', { - id: 'non-moderator', - name: 'Non Moderator', - email: 'non.moderator@example.org', - password: '1234', - }) - authenticatedUser = await nonModerator.toJson() + beforeEach(async () => { + authenticatedUser = await nonModerator.toJson() + }) + + it('non-moderator receives an authorization error', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], }) - it('throws authorization error', async () => { - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'Not Authorised!' }], + }) + }) + + describe('moderator', () => { + beforeEach(async () => { + authenticatedUser = await moderator.toJson() + const questionablePost = await factory.create('Post', { + id: 'should-i-be-disabled', + }) + const reportAgainstQuestionablePost = await factory.create('Report') + await Promise.all([ + reportAgainstQuestionablePost.relateTo(nonModerator, 'filed', { + resourceId: 'should-i-be-disabled', + reasonCategory: 'doxing', + reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!", + }), + reportAgainstQuestionablePost.relateTo(questionablePost, 'belongsTo'), + ]) + closeReportVariables = { + resourceId: 'should-i-be-disabled', + disable: false, + closed: true, + } + }) + + it('report can be closed without disabling resource', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: closeReportVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'should-i-be-disabled', disabled: false }, + report: { id: expect.any(String), closed: true }, + }, + }, + errors: undefined, + }) + }) + + it('creates only one review for multiple reviews by the same moderator on same resource', async () => { + await Promise.all([ + mutate({ + mutation: reviewMutation, + variables: { ...disableVariables, resourceId: 'should-i-be-disabled' }, + }), + mutate({ + mutation: reviewMutation, + variables: { ...enableVariables, resourceId: 'should-i-be-disabled' }, + }), + ]) + const cypher = + 'MATCH (:Report)<-[review:REVIEWED]-(moderator:User {id: "moderator-id"}) RETURN review' + const reviews = await neode.cypher(cypher) + expect(reviews.records).toHaveLength(1) + }) + + it('updates the updatedAt attribute', async () => { + const [firstReview, secondReview] = await Promise.all([ + mutate({ + mutation: reviewMutation, + variables: { ...disableVariables, resourceId: 'should-i-be-disabled' }, + }), + mutate({ + mutation: reviewMutation, + variables: { ...enableVariables, resourceId: 'should-i-be-disabled' }, + }), + ]) + expect(firstReview.data.review.updatedAt).toBeTruthy() + expect(Date.parse(firstReview.data.review.updatedAt)).toEqual(expect.any(Number)) + expect(secondReview.data.review.updatedAt).toBeTruthy() + expect(Date.parse(secondReview.data.review.updatedAt)).toEqual(expect.any(Number)) + expect(firstReview.data.review.updatedAt).not.toEqual(secondReview.data.review.updatedAt) + }) + }) + }) + + describe('review to disable', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await nonModerator.toJson() + }) + + it('non-moderator receives an authorization error', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('moderator', () => { + beforeEach(async () => { + authenticatedUser = await moderator.toJson() + }) + + describe('moderate a comment', () => { + beforeEach(async () => { + const trollingComment = await factory.create('Comment', { + id: 'comment-id', + }) + const reportAgainstTrollingComment = await factory.create('Report') + await Promise.all([ + reportAgainstTrollingComment.relateTo(nonModerator, 'filed', { + resourceId: 'comment-id', + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', + }), + reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'), + ]) + disableVariables = { + ...disableVariables, + resourceId: 'comment-id', + } + }) + + it('returns disabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Comment', id: 'comment-id' } } }, + errors: undefined, + }) + }) + + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Comment', id: 'comment-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, + errors: undefined, + }) + }) + + it('updates .disabled on comment', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'Comment', id: 'comment-id', disabled: true } }, + }, + errors: undefined, + }) + }) + + it('can be closed with one review', async () => { + closeReportVariables = { + ...disableVariables, + closed: true, + } + await expect( + mutate({ mutation: reviewMutation, variables: closeReportVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Comment', id: 'comment-id' }, + report: { id: expect.any(String), closed: true }, + }, + }, + errors: undefined, }) }) }) - describe('moderator', () => { + describe('moderate a post', () => { beforeEach(async () => { - authenticatedUser = await moderator.toJson() + const trollingPost = await factory.create('Post', { + id: 'post-id', + }) + const reportAgainstTrollingPost = await factory.create('Report') + await Promise.all([ + reportAgainstTrollingPost.relateTo(nonModerator, 'filed', { + resourceId: 'post-id', + reasonCategory: 'doxing', + reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!", + }), + reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'), + ]) + disableVariables = { + ...disableVariables, + resourceId: 'post-id', + } }) - describe('moderate a resource that is not a (Comment|Post|User) ', () => { - beforeEach(async () => { - variables = { - id: 'sample-tag-id', - } - await factory.create('Tag', { id: 'sample-tag-id' }) - }) - - it('returns null', async () => { - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: null }, - }) + it('returns disabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + }, + }, + errors: undefined, }) }) - describe('moderate a comment', () => { - beforeEach(async () => { - variables = {} - await factory.create('Comment', { - id: 'comment-id', - }) - }) - - it('returns disabled resource id', async () => { - variables = { id: 'comment-id' } - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'comment-id' }, - errors: undefined, - }) - }) - - it('changes .disabledBy', async () => { - variables = { id: 'comment-id' } - const before = { data: { Comment: [{ id: 'comment-id', disabledBy: null }] } } - const expected = { - data: { Comment: [{ id: 'comment-id', disabledBy: { id: 'moderator-id' } }] }, - } - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(before) - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'comment-id' }, - }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) - }) - - it('updates .disabled on comment', async () => { - variables = { id: 'comment-id' } - const before = { data: { Comment: [{ id: 'comment-id', disabled: false }] } } - const expected = { data: { Comment: [{ id: 'comment-id', disabled: true }] } } - - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(before) - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'comment-id' }, - }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, + errors: undefined, }) }) - describe('moderate a post', () => { - beforeEach(async () => { - variables = {} - await factory.create('Post', { - id: 'sample-post-id', - }) + it('updates .disabled on post', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Post', id: 'post-id', disabled: true } } }, + errors: undefined, }) + }) - it('returns disabled resource id', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'sample-post-id' }, - }) + it('can be closed with one review', async () => { + closeReportVariables = { + ...disableVariables, + closed: true, + } + await expect( + mutate({ mutation: reviewMutation, variables: closeReportVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + report: { id: expect.any(String), closed: true }, + }, + }, + errors: undefined, }) + }) + }) - it('changes .disabledBy', async () => { - variables = { id: 'sample-post-id' } - const before = { data: { Post: [{ id: 'sample-post-id', disabledBy: null }] } } - const expected = { - data: { Post: [{ id: 'sample-post-id', disabledBy: { id: 'moderator-id' } }] }, - } - - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(before) - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'sample-post-id' }, - }) - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) + describe('moderate a user', () => { + beforeEach(async () => { + const troll = await factory.create('User', { + id: 'user-id', }) + const reportAgainstTroll = await factory.create('Report') + await Promise.all([ + reportAgainstTroll.relateTo(nonModerator, 'filed', { + resourceId: 'user-id', + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', + }), + reportAgainstTroll.relateTo(troll, 'belongsTo'), + ]) + disableVariables = { + ...disableVariables, + resourceId: 'user-id', + } + }) - it('updates .disabled on post', async () => { - const before = { data: { Post: [{ id: 'sample-post-id', disabled: false }] } } - const expected = { data: { Post: [{ id: 'sample-post-id', disabled: true }] } } - variables = { id: 'sample-post-id' } + it('returns disabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'User', id: 'user-id' } } }, + errors: undefined, + }) + }) - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(before) - await expect(mutate({ mutation: disableMutation, variables })).resolves.toMatchObject({ - data: { disable: 'sample-post-id' }, - }) - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'User', id: 'user-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, + errors: undefined, + }) + }) + + it('updates .disabled on user', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: disableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'User', id: 'user-id', disabled: true } } }, + errors: undefined, + }) + }) + + it('can be closed with one review', async () => { + closeReportVariables = { + ...disableVariables, + closed: true, + } + await expect( + mutate({ mutation: reviewMutation, variables: closeReportVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'User', id: 'user-id' }, + report: { id: expect.any(String), closed: true }, + }, + }, + errors: undefined, }) }) }) }) }) - describe('enable', () => { + describe('review to re-enable after disabled', () => { describe('unautenticated user', () => { it('throws authorization error', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ + enableVariables = { + ...enableVariables, + resourceId: 'post-id', + } + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], }) }) @@ -228,17 +481,17 @@ describe('moderate resources', () => { describe('authenticated user', () => { describe('non moderator', () => { beforeEach(async () => { - nonModerator = await factory.create('User', { - id: 'non-moderator', - name: 'Non Moderator', - email: 'non.moderator@example.org', - password: '1234', - }) authenticatedUser = await nonModerator.toJson() }) + it('throws authorization error', async () => { - variables = { id: 'sample-post-id' } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ + enableVariables = { + ...enableVariables, + resourceId: 'post-id', + } + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ errors: [{ message: 'Not Authorised!' }], }) }) @@ -248,101 +501,197 @@ describe('moderate resources', () => { beforeEach(async () => { authenticatedUser = await moderator.toJson() }) - describe('moderate a resource that is not a (Comment|Post|User) ', () => { - beforeEach(async () => { - await Promise.all([factory.create('Tag', { id: 'sample-tag-id' })]) - }) - - it('returns null', async () => { - await expect( - mutate({ mutation: enableMutation, variables: { id: 'sample-tag-id' } }), - ).resolves.toMatchObject({ - data: { enable: null }, - }) - }) - }) describe('moderate a comment', () => { beforeEach(async () => { - variables = { id: 'comment-id' } - await factory.create('Comment', { + const trollingComment = await factory.create('Comment', { id: 'comment-id', }) - await mutate({ mutation: disableMutation, variables }) + const reportAgainstTrollingComment = await factory.create('Report') + await Promise.all([ + reportAgainstTrollingComment.relateTo(nonModerator, 'filed', { + resourceId: 'comment-id', + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', + }), + reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'), + ]) + await Promise.all([ + reportAgainstTrollingComment.relateTo(moderator, 'reviewed', { + ...disableVariables, + resourceId: 'comment-id', + }), + trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }), + ]) + enableVariables = { + ...enableVariables, + resourceId: 'comment-id', + } }) it('returns enabled resource id', async () => { - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Comment', id: 'comment-id' } } }, }) }) - it('changes .disabledBy', async () => { - const expected = { - data: { Comment: [{ id: 'comment-id', disabledBy: null }] }, - errors: undefined, - } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Comment', id: 'comment-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) }) it('updates .disabled on comment', async () => { - const expected = { - data: { Comment: [{ id: 'comment-id', disabled: false }] }, - errors: undefined, - } - - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'comment-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'Comment', id: 'comment-id', disabled: false } }, + }, }) - await expect(query({ query: commentQuery, variables })).resolves.toMatchObject(expected) }) }) describe('moderate a post', () => { beforeEach(async () => { - variables = { id: 'post-id' } - await factory.create('Post', { + const trollingPost = await factory.create('Post', { id: 'post-id', }) - await mutate({ mutation: disableMutation, variables }) + const reportAgainstTrollingPost = await factory.create('Report') + await Promise.all([ + reportAgainstTrollingPost.relateTo(nonModerator, 'filed', { + resourceId: 'post-id', + reasonCategory: 'doxing', + reasonDescription: + "This shouldn't be shown to anybody else! It's my private thing!", + }), + reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'), + ]) + await Promise.all([ + reportAgainstTrollingPost.relateTo(moderator, 'reviewed', { + ...disableVariables, + resourceId: 'comment-id', + }), + trollingPost.update({ disabled: true, updatedAt: new Date().toISOString() }), + ]) + enableVariables = { + ...enableVariables, + resourceId: 'post-id', + } }) it('returns enabled resource id', async () => { - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'Post', id: 'post-id' } } }, }) }) - it('changes .disabledBy', async () => { - const expected = { - data: { Post: [{ id: 'post-id', disabledBy: null }] }, - errors: undefined, - } - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'Post', id: 'post-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, }) - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) }) it('updates .disabled on post', async () => { - const expected = { - data: { Post: [{ id: 'post-id', disabled: false }] }, - errors: undefined, - } - - await expect(mutate({ mutation: enableMutation, variables })).resolves.toMatchObject({ - data: { enable: 'post-id' }, - errors: undefined, + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'Post', id: 'post-id', disabled: false } }, + }, + }) + }) + }) + + describe('moderate a user', () => { + beforeEach(async () => { + const troll = await factory.create('User', { + id: 'user-id', + }) + const reportAgainstTroll = await factory.create('Report') + await Promise.all([ + reportAgainstTroll.relateTo(nonModerator, 'filed', { + resourceId: 'user-id', + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', + }), + reportAgainstTroll.relateTo(troll, 'belongsTo'), + ]) + await Promise.all([ + reportAgainstTroll.relateTo(moderator, 'reviewed', { + ...disableVariables, + resourceId: 'comment-id', + }), + troll.update({ disabled: true, updatedAt: new Date().toISOString() }), + ]) + enableVariables = { + ...enableVariables, + resourceId: 'user-id', + } + }) + + it('returns enabled resource id', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { review: { resource: { __typename: 'User', id: 'user-id' } } }, + }) + }) + + it('returns .reviewed', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { + resource: { __typename: 'User', id: 'user-id' }, + report: { + id: expect.any(String), + reviewed: expect.arrayContaining([ + { createdAt: expect.any(String), moderator: { id: 'moderator-id' } }, + ]), + }, + }, + }, + }) + }) + + it('updates .disabled on user', async () => { + await expect( + mutate({ mutation: reviewMutation, variables: enableVariables }), + ).resolves.toMatchObject({ + data: { + review: { resource: { __typename: 'User', id: 'user-id', disabled: false } }, + }, }) - await expect(query({ query: postQuery, variables })).resolves.toMatchObject(expected) }) }) }) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 1322b10e4..2bd229b84 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -5,6 +5,7 @@ import { getBlockedUsers, getBlockedByUsers } from './users.js' import { mergeWith, isArray, isEmpty } from 'lodash' import { UserInputError } from 'apollo-server' import Resolver from './helpers/Resolver' + const filterForBlockedUsers = async (params, context) => { if (!context.user) return params const [blockedUsers, blockedByUsers] = await Promise.all([ @@ -318,7 +319,6 @@ export default { }, hasOne: { author: '<-[:WROTE]-(related:User)', - disabledBy: '<-[:DISABLED]-(related:User)', pinnedBy: '<-[:PINNED]-(related:User)', }, count: { diff --git a/backend/src/schema/resolvers/reports.js b/backend/src/schema/resolvers/reports.js index 8e12f1dba..fc93229ae 100644 --- a/backend/src/schema/resolvers/reports.js +++ b/backend/src/schema/resolvers/reports.js @@ -1,57 +1,47 @@ +const transformReturnType = record => { + return { + ...record.get('report').properties, + resource: { + __typename: record.get('type'), + ...record.get('resource').properties, + }, + } +} + export default { Mutation: { - report: async (_parent, params, context, _resolveInfo) => { + fileReport: async (_parent, params, context, _resolveInfo) => { let createdRelationshipWithNestedAttributes const { resourceId, reasonCategory, reasonDescription } = params const { driver, user } = context const session = driver.session() - try { - const writeTxResultPromise = session.writeTransaction(async txc => { - const reportRelationshipTransactionResponse = await txc.run( - ` + const reportWriteTxResultPromise = session.writeTransaction(async txc => { + const reportTransactionResponse = await txc.run( + ` MATCH (submitter:User {id: $submitterId}) MATCH (resource {id: $resourceId}) - WHERE resource:User OR resource:Comment OR resource:Post - CREATE (resource)<-[report:REPORTED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter) - RETURN report, submitter, resource, labels(resource)[0] as type + WHERE resource:User OR resource:Post OR resource:Comment + MERGE (resource)<-[:BELONGS_TO]-(report:Report {closed: false}) + ON CREATE SET report.id = randomUUID(), report.createdAt = $createdAt, report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.disable = resource.disabled, report.closed = false + WITH submitter, resource, report + CREATE (report)<-[filed:FILED {createdAt: $createdAt, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription}]-(submitter) + + RETURN report, resource, labels(resource)[0] AS type `, - { - resourceId, - submitterId: user.id, - createdAt: new Date().toISOString(), - reasonCategory, - reasonDescription, - }, - ) - return reportRelationshipTransactionResponse.records.map(record => ({ - report: record.get('report'), - submitter: record.get('submitter'), - resource: record.get('resource').properties, - type: record.get('type'), - })) - }) - const txResult = await writeTxResultPromise + { + resourceId, + submitterId: user.id, + createdAt: new Date().toISOString(), + reasonCategory, + reasonDescription, + }, + ) + return reportTransactionResponse.records.map(transformReturnType) + }) + try { + const txResult = await reportWriteTxResultPromise if (!txResult[0]) return null - const { report, submitter, resource, type } = txResult[0] - createdRelationshipWithNestedAttributes = { - ...report.properties, - post: null, - comment: null, - user: null, - submitter: submitter.properties, - type, - } - switch (type) { - case 'Post': - createdRelationshipWithNestedAttributes.post = resource - break - case 'Comment': - createdRelationshipWithNestedAttributes.comment = resource - break - case 'User': - createdRelationshipWithNestedAttributes.user = resource - break - } + createdRelationshipWithNestedAttributes = txResult[0] } finally { session.close() } @@ -61,8 +51,8 @@ export default { Query: { reports: async (_parent, params, context, _resolveInfo) => { const { driver } = context - let response - let orderByClause + const session = driver.session() + let reports, orderByClause switch (params.orderBy) { case 'createdAt_asc': orderByClause = 'ORDER BY report.createdAt ASC' @@ -73,56 +63,97 @@ export default { default: orderByClause = '' } - const session = driver.session() - try { - const cypher = ` - MATCH (submitter:User)-[report:REPORTED]->(resource) - WHERE resource:User OR resource:Comment OR resource:Post - RETURN report, submitter, resource, labels(resource)[0] as type + const reportReadTxPromise = session.readTransaction(async tx => { + const allReportsTransactionResponse = await tx.run( + ` + MATCH (submitter:User)-[filed:FILED]->(report:Report)-[:BELONGS_TO]->(resource) + WHERE resource:User OR resource:Post OR resource:Comment + RETURN DISTINCT report, resource, labels(resource)[0] as type ${orderByClause} - ` - const result = await session.run(cypher, {}) - const dbResponse = result.records.map(r => { - return { - report: r.get('report'), - submitter: r.get('submitter'), - resource: r.get('resource'), - type: r.get('type'), + `, + {}, + ) + return allReportsTransactionResponse.records.map(transformReturnType) + }) + try { + const txResult = await reportReadTxPromise + if (!txResult[0]) return null + reports = txResult + } finally { + session.close() + } + return reports + }, + }, + Report: { + filed: async (parent, _params, context, _resolveInfo) => { + if (typeof parent.filed !== 'undefined') return parent.filed + const session = context.driver.session() + const { id } = parent + let filed + const readTxPromise = session.readTransaction(async tx => { + const allReportsTransactionResponse = await tx.run( + ` + MATCH (submitter:User)-[filed:FILED]->(report:Report {id: $id}) + RETURN filed, submitter + `, + { id }, + ) + return allReportsTransactionResponse.records.map(record => ({ + submitter: record.get('submitter').properties, + filed: record.get('filed').properties, + })) + }) + try { + const txResult = await readTxPromise + if (!txResult[0]) return null + filed = txResult.map(reportedRecord => { + const { submitter, filed } = reportedRecord + const relationshipWithNestedAttributes = { + ...filed, + submitter, } - }) - if (!dbResponse) return null - - response = [] - dbResponse.forEach(ele => { - const { report, submitter, resource, type } = ele - - const responseEle = { - ...report.properties, - post: null, - comment: null, - user: null, - submitter: submitter.properties, - type, - } - - switch (type) { - case 'Post': - responseEle.post = resource.properties - break - case 'Comment': - responseEle.comment = resource.properties - break - case 'User': - responseEle.user = resource.properties - break - } - response.push(responseEle) + return relationshipWithNestedAttributes }) } finally { session.close() } - - return response + return filed + }, + reviewed: async (parent, _params, context, _resolveInfo) => { + if (typeof parent.reviewed !== 'undefined') return parent.reviewed + const session = context.driver.session() + const { id } = parent + let reviewed + const readTxPromise = session.readTransaction(async tx => { + const allReportsTransactionResponse = await tx.run( + ` + MATCH (resource)<-[:BELONGS_TO]-(report:Report {id: $id})<-[review:REVIEWED]-(moderator:User) + RETURN moderator, review + ORDER BY report.updatedAt DESC, review.updatedAt DESC + `, + { id }, + ) + return allReportsTransactionResponse.records.map(record => ({ + review: record.get('review').properties, + moderator: record.get('moderator').properties, + })) + }) + try { + const txResult = await readTxPromise + if (!txResult[0]) return null + reviewed = txResult.map(reportedRecord => { + const { review, moderator } = reportedRecord + const relationshipWithNestedAttributes = { + ...review, + moderator, + } + return relationshipWithNestedAttributes + }) + } finally { + session.close() + } + return reviewed }, }, } diff --git a/backend/src/schema/resolvers/reports.spec.js b/backend/src/schema/resolvers/reports.spec.js index 2ecd1f20d..c0a9d3afb 100644 --- a/backend/src/schema/resolvers/reports.spec.js +++ b/backend/src/schema/resolvers/reports.spec.js @@ -8,31 +8,41 @@ const factory = Factory() const instance = getNeode() const driver = getDriver() -describe('report resources', () => { - let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser +describe('file a report on a resource', () => { + let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser const categoryIds = ['cat9'] const reportMutation = gql` mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report( + fileReport( resourceId: $resourceId reasonCategory: $reasonCategory reasonDescription: $reasonDescription ) { + id createdAt - reasonCategory - reasonDescription - type - submitter { - email + updatedAt + disable + closed + rule + resource { + __typename + ... on User { + name + } + ... on Post { + title + } + ... on Comment { + content + } } - user { - name - } - post { - title - } - comment { - content + filed { + submitter { + id + } + createdAt + reasonCategory + reasonDescription } } } @@ -67,7 +77,7 @@ describe('report resources', () => { it('throws authorization error', async () => { authenticatedUser = null await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: [{ message: 'Not Authorised!' }], }) }) @@ -81,6 +91,12 @@ describe('report resources', () => { email: 'test@example.org', password: '1234', }) + otherReportingUser = await factory.create('User', { + id: 'other-reporting-user-id', + role: 'user', + email: 'reporting@example.org', + password: '1234', + }) await factory.create('User', { id: 'abusive-user-id', role: 'user', @@ -99,15 +115,15 @@ describe('report resources', () => { describe('invalid resource id', () => { it('returns null', async () => { await expect(mutate({ mutation: reportMutation, variables })).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: undefined, }) }) }) describe('valid resource', () => { - describe('reported resource is a user', () => { - it('returns type "User"', async () => { + describe('creates report', () => { + it('which belongs to resource', async () => { await expect( mutate({ mutation: reportMutation, @@ -115,15 +131,28 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'User', + fileReport: { + id: expect.any(String), }, }, errors: undefined, }) }) - it('returns resource in user attribute', async () => { + it('creates only one report for multiple reports on the same resource', async () => { + const firstReport = await mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }) + authenticatedUser = await otherReportingUser.toJson() + const secondReport = await mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }) + expect(firstReport.data.fileReport.id).toEqual(secondReport.data.fileReport.id) + }) + + it('returns the rule for how the report was decided', async () => { await expect( mutate({ mutation: reportMutation, @@ -131,8 +160,46 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - user: { + fileReport: { + rule: 'latestReviewUpdatedAtRules', + }, + }, + errors: undefined, + }) + }) + it.todo('creates multiple filed reports') + }) + + describe('reported resource is a user', () => { + it('returns __typename "User"', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + fileReport: { + resource: { + __typename: 'User', + }, + }, + }, + errors: undefined, + }) + }) + + it('returns user attribute info', async () => { + await expect( + mutate({ + mutation: reportMutation, + variables: { ...variables, resourceId: 'abusive-user-id' }, + }), + ).resolves.toMatchObject({ + data: { + fileReport: { + resource: { + __typename: 'User', name: 'abusive-user', }, }, @@ -149,10 +216,14 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - submitter: { - email: 'test@example.org', - }, + fileReport: { + filed: [ + { + submitter: { + id: 'current-user-id', + }, + }, + ], }, }, errors: undefined, @@ -167,7 +238,7 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { + fileReport: { createdAt: expect.any(String), }, }, @@ -187,8 +258,12 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonCategory: 'criminal_behavior_violation_german_law', + fileReport: { + filed: [ + { + reasonCategory: 'criminal_behavior_violation_german_law', + }, + ], }, }, errors: undefined, @@ -228,15 +303,19 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonDescription: 'My reason!', + fileReport: { + filed: [ + { + reasonDescription: 'My reason!', + }, + ], }, }, errors: undefined, }) }) - it('sanitize the reason description', async () => { + it('sanitizes the reason description', async () => { await expect( mutate({ mutation: reportMutation, @@ -248,8 +327,12 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - reasonDescription: 'My reason !', + fileReport: { + filed: [ + { + reasonDescription: 'My reason !', + }, + ], }, }, errors: undefined, @@ -278,8 +361,10 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'Post', + fileReport: { + resource: { + __typename: 'Post', + }, }, }, errors: undefined, @@ -297,8 +382,9 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - post: { + fileReport: { + resource: { + __typename: 'Post', title: 'This is a post that is going to be reported', }, }, @@ -306,25 +392,6 @@ describe('report resources', () => { errors: undefined, }) }) - - it('returns null in user attribute', async () => { - await expect( - mutate({ - mutation: reportMutation, - variables: { - ...variables, - resourceId: 'post-to-report-id', - }, - }), - ).resolves.toMatchObject({ - data: { - report: { - user: null, - }, - }, - errors: undefined, - }) - }) }) describe('reported resource is a comment', () => { @@ -356,8 +423,10 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - type: 'Comment', + fileReport: { + resource: { + __typename: 'Comment', + }, }, }, errors: undefined, @@ -375,8 +444,9 @@ describe('report resources', () => { }), ).resolves.toMatchObject({ data: { - report: { - comment: { + fileReport: { + resource: { + __typename: 'Comment', content: 'Post comment to be reported.', }, }, @@ -403,7 +473,7 @@ describe('report resources', () => { }, }), ).resolves.toMatchObject({ - data: { report: null }, + data: { fileReport: null }, errors: undefined, }) }) @@ -411,25 +481,35 @@ describe('report resources', () => { }) }) }) + describe('query for reported resource', () => { const reportsQuery = gql` query { reports(orderBy: createdAt_desc) { + id createdAt - reasonCategory - reasonDescription - submitter { - id + updatedAt + disable + closed + resource { + __typename + ... on User { + id + } + ... on Post { + id + } + ... on Comment { + id + } } - type - user { - id - } - post { - id - } - comment { - id + filed { + submitter { + id + } + createdAt + reasonCategory + reasonDescription } } } @@ -437,7 +517,6 @@ describe('report resources', () => { beforeEach(async () => { authenticatedUser = null - moderator = await factory.create('User', { id: 'moderator-1', role: 'moderator', @@ -518,6 +597,7 @@ describe('report resources', () => { ]) authenticatedUser = null }) + describe('unauthenticated', () => { it('throws authorization error', async () => { authenticatedUser = null @@ -527,6 +607,7 @@ describe('report resources', () => { }) }) }) + describe('authenticated', () => { it('role "user" gets no reports', async () => { authenticatedUser = await currentUser.toJson() @@ -538,49 +619,69 @@ describe('report resources', () => { it('role "moderator" gets reports', async () => { const expected = { - // to check 'orderBy: createdAt_desc' is not possible here, because 'createdAt' does not differ reports: expect.arrayContaining([ expect.objectContaining({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'User', - user: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'User', id: 'abusive-user-1', - }), - post: null, - comment: null, + }, + filed: expect.arrayContaining([ + expect.objectContaining({ + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + createdAt: expect.any(String), + reasonCategory: 'doxing', + reasonDescription: 'This user is harassing me with bigoted remarks', + }), + ]), }), expect.objectContaining({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'other', - reasonDescription: 'This comment is bigoted', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'Post', - user: null, - post: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'Post', id: 'abusive-post-1', - }), - comment: null, + }, + filed: expect.arrayContaining([ + expect.objectContaining({ + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + createdAt: expect.any(String), + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', + }), + ]), }), expect.objectContaining({ + id: expect.any(String), createdAt: expect.any(String), - reasonCategory: 'discrimination_etc', - reasonDescription: 'This post is bigoted', - submitter: expect.objectContaining({ - id: 'current-user-id', - }), - type: 'Comment', - user: null, - post: null, - comment: expect.objectContaining({ + updatedAt: expect.any(String), + disable: false, + closed: false, + resource: { + __typename: 'Comment', id: 'abusive-comment-1', - }), + }, + filed: expect.arrayContaining([ + expect.objectContaining({ + submitter: expect.objectContaining({ + id: 'current-user-id', + }), + createdAt: expect.any(String), + reasonCategory: 'discrimination_etc', + reasonDescription: 'This post is bigoted', + }), + ]), }), ]), } diff --git a/backend/src/schema/resolvers/user_management.spec.js b/backend/src/schema/resolvers/user_management.spec.js index df8454ebb..e67b90c8d 100644 --- a/backend/src/schema/resolvers/user_management.spec.js +++ b/backend/src/schema/resolvers/user_management.spec.js @@ -9,25 +9,25 @@ import { neode as getNeode } from '../../bootstrap/neo4j' const factory = Factory() const neode = getNeode() -let query -let mutate -let variables -let req -let user +let query, mutate, variables, req, user const disable = async id => { - await factory.create('User', { id: 'u2', role: 'moderator' }) - const moderatorBearerToken = encode({ id: 'u2' }) - req = { headers: { authorization: `Bearer ${moderatorBearerToken}` } } - await mutate({ - mutation: gql` - mutation($id: ID!) { - disable(id: $id) - } - `, - variables: { id }, - }) - req = { headers: {} } + const moderator = await factory.create('User', { id: 'u2', role: 'moderator' }) + const user = await neode.find('User', id) + const reportAgainstUser = await factory.create('Report') + await Promise.all([ + reportAgainstUser.relateTo(moderator, 'filed', { + resourceId: id, + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', + }), + reportAgainstUser.relateTo(user, 'belongsTo'), + ]) + const disableVariables = { resourceId: user.id, disable: true, closed: false } + await Promise.all([ + reportAgainstUser.relateTo(moderator, 'reviewed', disableVariables), + user.update({ disabled: true, updatedAt: new Date().toISOString() }), + ]) } beforeEach(() => { diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 02ed8dbac..c44e3f44b 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -212,7 +212,6 @@ export default { }, hasOne: { invitedBy: '<-[:INVITED]-(related:User)', - disabledBy: '<-[:DISABLED]-(related:User)', location: '-[:IS_IN]->(related:Location)', }, hasMany: { diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 27fd2206c..35998b935 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -24,8 +24,6 @@ type Mutation { changePassword(oldPassword: String!, newPassword: String!): String! requestPasswordReset(email: String!): Boolean! resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean! - disable(id: ID!): ID - enable(id: ID!): ID # Shout the given Type and ID shout(id: ID!, type: ShoutTypeEnum): Boolean! # Unshout the given Type and ID diff --git a/backend/src/schema/types/type/Comment.gql b/backend/src/schema/types/type/Comment.gql index ba9d7a3fc..cf53df41d 100644 --- a/backend/src/schema/types/type/Comment.gql +++ b/backend/src/schema/types/type/Comment.gql @@ -47,7 +47,6 @@ type Comment { updatedAt: String deleted: Boolean disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") } type Query { diff --git a/backend/src/schema/types/type/FILED.gql b/backend/src/schema/types/type/FILED.gql new file mode 100644 index 000000000..955af0bb8 --- /dev/null +++ b/backend/src/schema/types/type/FILED.gql @@ -0,0 +1,23 @@ +type FILED { + createdAt: String! + reasonCategory: ReasonCategory! + reasonDescription: String! + submitter: User +} + +# this list equals the strings of an array in file "webapp/constants/modals.js" +enum ReasonCategory { + other + discrimination_etc + pornographic_content_links + glorific_trivia_of_cruel_inhuman_acts + doxing + intentional_intimidation_stalking_persecution + advert_products_services_commercial + criminal_behavior_violation_german_law +} + +enum ReportOrdering { + createdAt_asc + createdAt_desc +} diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 5557cbd54..af91460f7 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -26,7 +26,7 @@ enum NotificationReason { type Query { notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED] } - + type Mutation { markAsRead(id: ID!): NOTIFIED } diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index b29ac5386..4837aab1e 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -114,7 +114,7 @@ type Post { objectId: String author: User @relation(name: "WROTE", direction: "IN") title: String! - slug: String + slug: String! content: String! contentExcerpt: String image: String @@ -123,7 +123,6 @@ type Post { deleted: Boolean disabled: Boolean pinned: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") createdAt: String updatedAt: String language: String diff --git a/backend/src/schema/types/type/REPORTED.gql b/backend/src/schema/types/type/REPORTED.gql deleted file mode 100644 index 5042672a7..000000000 --- a/backend/src/schema/types/type/REPORTED.gql +++ /dev/null @@ -1,43 +0,0 @@ -type REPORTED { - createdAt: String - reasonCategory: ReasonCategory - reasonDescription: String - submitter: User - @cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN user") - # not yet supported - # resource: ReportResource - # @cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN resource") - type: String - @cypher(statement: "MATCH (resource)<-[:REPORTED]-(user:User) RETURN labels(resource)[0]") - user: User - post: Post - comment: Comment -} - -# this list equals the strings of an array in file "webapp/constants/modals.js" -enum ReasonCategory { - other - discrimination_etc - pornographic_content_links - glorific_trivia_of_cruel_inhuman_acts - doxing - intentional_intimidation_stalking_persecution - advert_products_services_commercial - criminal_behavior_violation_german_law -} - -# not yet supported -# union ReportResource = User | Post | Comment - -enum ReportOrdering { - createdAt_asc - createdAt_desc -} - -type Query { - reports(orderBy: ReportOrdering): [REPORTED] -} - -type Mutation { - report(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): REPORTED -} diff --git a/backend/src/schema/types/type/REVIEWED.gql b/backend/src/schema/types/type/REVIEWED.gql new file mode 100644 index 000000000..aea005abe --- /dev/null +++ b/backend/src/schema/types/type/REVIEWED.gql @@ -0,0 +1,15 @@ +type REVIEWED { + createdAt: String! + updatedAt: String! + disable: Boolean! + closed: Boolean! + report: Report + # @cypher(statement: "MATCH (report:Report)<-[this:REVIEWED]-(:User) RETURN report") + moderator: User + resource: ReviewedResource +} +union ReviewedResource = User | Post | Comment + +type Mutation { + review(resourceId: ID!, disable: Boolean, closed: Boolean): REVIEWED +} diff --git a/backend/src/schema/types/type/Report.gql b/backend/src/schema/types/type/Report.gql new file mode 100644 index 000000000..49e5bdae3 --- /dev/null +++ b/backend/src/schema/types/type/Report.gql @@ -0,0 +1,25 @@ +type Report { + id: ID! + createdAt: String! + updatedAt: String! + rule: ReportRule! + disable: Boolean! + closed: Boolean! + filed: [FILED] + reviewed: [REVIEWED] + resource: ReportedResource +} + +union ReportedResource = User | Post | Comment + +enum ReportRule { + latestReviewUpdatedAtRules +} + +type Mutation { + fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): Report +} + +type Query { + reports(orderBy: ReportOrdering): [Report] +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 53e739988..243f45322 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -33,7 +33,6 @@ type User { coverImg: String deleted: Boolean disabled: Boolean - disabledBy: User @relation(name: "DISABLED", direction: "IN") role: UserGroup! publicKey: String invitedBy: User @relation(name: "INVITED", direction: "IN") @@ -44,8 +43,6 @@ type User { about: String socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") - # createdAt: DateTime - # updatedAt: DateTime createdAt: String updatedAt: String diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 1b090530b..441fe47d5 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -10,6 +10,7 @@ import createLocation from './locations.js' import createEmailAddress from './emailAddresses.js' import createDonations from './donations.js' import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' +import createReport from './reports.js' const factories = { Badge: createBadge, @@ -23,6 +24,7 @@ const factories = { EmailAddress: createEmailAddress, UnverifiedEmailAddress: createUnverifiedEmailAddresss, Donations: createDonations, + Report: createReport, } export const cleanDatabase = async (options = {}) => { diff --git a/backend/src/seed/factories/reports.js b/backend/src/seed/factories/reports.js new file mode 100644 index 000000000..e2d5ec4dc --- /dev/null +++ b/backend/src/seed/factories/reports.js @@ -0,0 +1,7 @@ +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + return neodeInstance.create('Report', args) + }, + } +} diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 692d95542..d059400a4 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -524,7 +524,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ]) authenticatedUser = null - await Promise.all([ + const comments = await Promise.all([ factory.create('Comment', { author: jennyRostock, id: 'c1', @@ -541,7 +541,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] postId: 'p3', }), factory.create('Comment', { - author: bobDerBaumeister, + author: jennyRostock, id: 'c5', postId: 'p3', }), @@ -581,6 +581,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] postId: 'p15', }), ]) + const trollingComment = comments[0] await Promise.all([ democracy.relateTo(p3, 'post'), @@ -644,68 +645,107 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] louie.relateTo(p10, 'shouted'), ]) - const disableMutation = gql` - mutation($id: ID!) { - disable(id: $id) - } - ` - authenticatedUser = await bobDerBaumeister.toJson() - await Promise.all([ - mutate({ - mutation: disableMutation, - variables: { - id: 'p11', - }, - }), - mutate({ - mutation: disableMutation, - variables: { - id: 'c5', - }, - }), + const reports = await Promise.all([ + factory.create('Report'), + factory.create('Report'), + factory.create('Report'), ]) - authenticatedUser = null + const reportAgainstDagobert = reports[0] + const reportAgainstTrollingPost = reports[1] + const reportAgainstTrollingComment = reports[2] - // There is no error logged or the 'try' fails if this mutation is wrong. Why? - const reportMutation = gql` - mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report( - resourceId: $resourceId - reasonCategory: $reasonCategory - reasonDescription: $reasonDescription - ) { - type - } - } - ` - authenticatedUser = await huey.toJson() + // report resource first time await Promise.all([ - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'c1', - reasonCategory: 'other', - reasonDescription: 'This comment is bigoted', - }, + reportAgainstDagobert.relateTo(jennyRostock, 'filed', { + resourceId: 'u7', + reasonCategory: 'discrimination_etc', + reasonDescription: 'This user is harassing me with bigoted remarks!', }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'p1', - reasonCategory: 'discrimination_etc', - reasonDescription: 'This post is bigoted', - }, + reportAgainstDagobert.relateTo(dagobert, 'belongsTo'), + reportAgainstTrollingPost.relateTo(jennyRostock, 'filed', { + resourceId: 'p2', + reasonCategory: 'doxing', + reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!", }), - mutate({ - mutation: reportMutation, - variables: { - resourceId: 'u1', - reasonCategory: 'doxing', - reasonDescription: 'This user is harassing me with bigoted remarks', - }, + reportAgainstTrollingPost.relateTo(p2, 'belongsTo'), + reportAgainstTrollingComment.relateTo(huey, 'filed', { + resourceId: 'c1', + reasonCategory: 'other', + reasonDescription: 'This comment is bigoted', }), + reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'), + ]) + + // report resource a second time + await Promise.all([ + reportAgainstDagobert.relateTo(louie, 'filed', { + resourceId: 'u7', + reasonCategory: 'discrimination_etc', + reasonDescription: 'this user is attacking me for who I am!', + }), + reportAgainstDagobert.relateTo(dagobert, 'belongsTo'), + reportAgainstTrollingPost.relateTo(peterLustig, 'filed', { + resourceId: 'p2', + reasonCategory: 'discrimination_etc', + reasonDescription: 'This post is bigoted', + }), + reportAgainstTrollingPost.relateTo(p2, 'belongsTo'), + + reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'filed', { + resourceId: 'c1', + reasonCategory: 'pornographic_content_links', + reasonDescription: 'This comment is porno!!!', + }), + reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'), + ]) + + const disableVariables = { + resourceId: 'undefined-resource', + disable: true, + closed: false, + } + + // review resource first time + await Promise.all([ + reportAgainstDagobert.relateTo(bobDerBaumeister, 'reviewed', { + ...disableVariables, + resourceId: 'u7', + }), + dagobert.update({ disabled: true, updatedAt: new Date().toISOString() }), + reportAgainstTrollingPost.relateTo(peterLustig, 'reviewed', { + ...disableVariables, + resourceId: 'p2', + }), + p2.update({ disabled: true, updatedAt: new Date().toISOString() }), + reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'reviewed', { + ...disableVariables, + resourceId: 'c1', + }), + trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }), + ]) + + // second review of resource and close report + await Promise.all([ + reportAgainstDagobert.relateTo(peterLustig, 'reviewed', { + resourceId: 'u7', + disable: false, + closed: true, + }), + dagobert.update({ disabled: false, updatedAt: new Date().toISOString(), closed: true }), + reportAgainstTrollingPost.relateTo(bobDerBaumeister, 'reviewed', { + resourceId: 'p2', + disable: true, + closed: true, + }), + p2.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }), + reportAgainstTrollingComment.relateTo(peterLustig, 'reviewed', { + ...disableVariables, + resourceId: 'c1', + disable: true, + closed: true, + }), + trollingComment.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }), ]) - authenticatedUser = null await Promise.all( [...Array(30).keys()].map(i => { diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 17481befe..9f62a2818 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -129,8 +129,8 @@ Given('somebody reported the following posts:', table => { .create('User', submitter) .authenticateAs(submitter) .mutate(`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { - report(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) { - type + fileReport(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) { + id } }`, { resourceId, diff --git a/neo4j/change_disabled_relationship_to_report_node.sh b/neo4j/change_disabled_relationship_to_report_node.sh new file mode 100755 index 000000000..2f44b8e59 --- /dev/null +++ b/neo4j/change_disabled_relationship_to_report_node.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +ENV_FILE=$(dirname "$0")/.env +[[ -f "$ENV_FILE" ]] && source "$ENV_FILE" + +if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then + echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables." + echo "Database manipulation is not possible without connecting to the database." + echo "E.g. you could \`cp .env.template .env\` unless you run the script in a docker container" +fi + +until echo 'RETURN "Connection successful" as info;' | cypher-shell +do + echo "Connecting to neo4j failed, trying again..." + sleep 1 +done + +echo " +// convert old DISABLED to new REVIEWED-Report-BELONGS_TO structure +MATCH (moderator:User)-[disabled:DISABLED]->(disabledResource) +WHERE disabledResource:User OR disabledResource:Comment OR disabledResource:Post +DELETE disabled +CREATE (moderator)-[review:REVIEWED]->(report:Report)-[:BELONGS_TO]->(disabledResource) +SET review.createdAt = toString(datetime()), review.updatedAt = review.createdAt, review.disable = true +SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false + +// if disabledResource has no filed report, then create a moderators default filed report +WITH moderator, disabledResource, report +OPTIONAL MATCH (disabledResourceReporter:User)-[existingFiledReport:FILED]->(disabledResource) +FOREACH(disabledResource IN CASE WHEN existingFiledReport IS NULL THEN [1] ELSE [] END | + CREATE (moderator)-[addModeratorReport:FILED]->(report) + SET addModeratorReport.createdAt = toString(datetime()), addModeratorReport.reasonCategory = 'other', addModeratorReport.reasonDescription = 'Old DISABLED relations didn't enforce mandatory reporting !!! Created automatically to ensure database consistency! Creation date is when the database manipulation happened.' +) +FOREACH(disabledResource IN CASE WHEN existingFiledReport IS NOT NULL THEN [1] ELSE [] END | + CREATE (disabledResourceReporter)-[moveModeratorReport:FILED]->(report) + SET moveModeratorReport = existingFiledReport + DELETE existingFiledReport +) + +RETURN disabledResource {.id}; +" | cypher-shell + +echo " +// for FILED resources without DISABLED relation which are handled above, create new FILED-Report-BELONGS_TO structure +MATCH (reporter:User)-[oldReport:REPORTED]->(notDisabledResource) +WHERE notDisabledResource:User OR notDisabledResource:Comment OR notDisabledResource:Post +MERGE (report:Report)-[:BELONGS_TO]->(notDisabledResource) +ON CREATE SET report.id = randomUUID(), report.createdAt = toString(datetime()), report.updatedAt = report.createdAt, report.rule = 'latestReviewUpdatedAtRules', report.closed = false +CREATE (reporter)-[filed:FILED]->(report) +SET report = oldReport +DELETE oldReport + +RETURN notDisabledResource {.id}; +" | cypher-shell + diff --git a/neo4j/change_report_node_to_relationship.sh b/neo4j/change_report_node_to_relationship.sh deleted file mode 100755 index f8dd639be..000000000 --- a/neo4j/change_report_node_to_relationship.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -ENV_FILE=$(dirname "$0")/.env -[[ -f "$ENV_FILE" ]] && source "$ENV_FILE" - -if [ -z "$NEO4J_USERNAME" ] || [ -z "$NEO4J_PASSWORD" ]; then - echo "Please set NEO4J_USERNAME and NEO4J_PASSWORD environment variables." - echo "Database manipulation is not possible without connecting to the database." - echo "E.g. you could \`cp .env.template .env\` unless you run the script in a docker container" -fi - -until echo 'RETURN "Connection successful" as info;' | cypher-shell -do - echo "Connecting to neo4j failed, trying again..." - sleep 1 -done - -echo " -MATCH (submitter:User)-[:REPORTED]->(report:Report)-[:REPORTED]->(resource) -DETACH DELETE report -CREATE (submitter)-[reported:REPORTED]->(resource) -SET reported.createdAt = toString(datetime()) -SET reported.reasonCategory = 'other' -SET reported.reasonDescription = '!!! Created automatically to ensure database consistency! Creation date is when the database manipulation happened.' -RETURN reported; -" | cypher-shell diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index 888c167e9..25ed62f68 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -1,19 +1,9 @@ - - diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js index 485c43145..8f93aa4a4 100644 --- a/webapp/components/ContentMenu/ContentMenu.spec.js +++ b/webapp/components/ContentMenu/ContentMenu.spec.js @@ -85,7 +85,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'post.menu.delete') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('delete') + expect(openModalSpy).toHaveBeenCalledWith('confirm', 'delete') }) }) @@ -166,7 +166,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'comment.menu.delete') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('delete') + expect(openModalSpy).toHaveBeenCalledWith('confirm', 'delete') }) }) @@ -332,7 +332,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.contribution.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release comments', () => { @@ -350,7 +350,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.comment.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release users', () => { @@ -368,7 +368,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.user.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) it('can release organizations', () => { @@ -386,7 +386,7 @@ describe('ContentMenu.vue', () => { .filter(item => item.text() === 'release.organization.title') .at(0) .trigger('click') - expect(openModalSpy).toHaveBeenCalledWith('release', 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8') + expect(openModalSpy).toHaveBeenCalledWith('release') }) }) diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index f0d9dc8d3..d4c567437 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -70,7 +70,7 @@ export default { routes.push({ name: this.$t(`post.menu.delete`), callback: () => { - this.openModal('delete') + this.openModal('confirm', 'delete') }, icon: 'trash', }) @@ -108,7 +108,7 @@ export default { routes.push({ name: this.$t(`comment.menu.delete`), callback: () => { - this.openModal('delete') + this.openModal('confirm', 'delete') }, icon: 'trash', }) @@ -137,7 +137,7 @@ export default { routes.push({ name: this.$t(`release.${this.resourceType}.title`), callback: () => { - this.openModal('release', this.resource.id) + this.openModal('release') }, icon: 'eye', }) @@ -190,13 +190,13 @@ export default { } toggleMenu() }, - openModal(dialog) { + openModal(dialog, modalDataName = null) { this.$store.commit('modal/SET_OPEN', { name: dialog, data: { type: this.resourceType, resource: this.resource, - modalsData: this.modalsData, + modalData: modalDataName ? this.modalsData[modalDataName] : {}, }, }) }, diff --git a/webapp/components/DropdownFilter/DropdownFilter.spec.js b/webapp/components/DropdownFilter/DropdownFilter.spec.js index 8020a487f..8fb1b408f 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.spec.js +++ b/webapp/components/DropdownFilter/DropdownFilter.spec.js @@ -64,9 +64,9 @@ describe('DropdownFilter.vue', () => { expect(unreadLink.text()).toEqual('Unread') }) - it('clicking on menu item emits filterNotifications', () => { + it('clicking on menu item emits filter', () => { allLink.trigger('click') - expect(wrapper.emitted().filterNotifications[0]).toEqual( + expect(wrapper.emitted().filter[0]).toEqual( propsData.filterOptions.filter(option => option.label === 'All'), ) }) diff --git a/webapp/components/DropdownFilter/DropdownFilter.story.js b/webapp/components/DropdownFilter/DropdownFilter.story.js index 0703c5c47..9bd750ac1 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.story.js +++ b/webapp/components/DropdownFilter/DropdownFilter.story.js @@ -20,10 +20,10 @@ storiesOf('DropdownFilter', module) selected: filterOptions[0].label, }), methods: { - filterNotifications: action('filterNotifications'), + filter: action('filter'), }, template: ``, diff --git a/webapp/components/DropdownFilter/DropdownFilter.vue b/webapp/components/DropdownFilter/DropdownFilter.vue index 2a3637f41..bfa78e709 100644 --- a/webapp/components/DropdownFilter/DropdownFilter.vue +++ b/webapp/components/DropdownFilter/DropdownFilter.vue @@ -25,7 +25,7 @@ class="dropdown-menu-item" :route="item.route" :parents="item.parents" - @click.stop.prevent="filterNotifications(item.route, toggleMenu)" + @click.stop.prevent="filter(item.route, toggleMenu)" > {{ item.route.label }} @@ -44,8 +44,8 @@ export default { filterOptions: { type: Array, default: () => [] }, }, methods: { - filterNotifications(option, toggleMenu) { - this.$emit('filterNotifications', option) + filter(option, toggleMenu) { + this.$emit('filter', option) toggleMenu() }, }, diff --git a/webapp/components/Modal.vue b/webapp/components/Modal.vue index 3c83a0922..84a1871b5 100644 --- a/webapp/components/Modal.vue +++ b/webapp/components/Modal.vue @@ -23,11 +23,11 @@ @close="close" /> diff --git a/webapp/components/Modal/ConfirmModal.vue b/webapp/components/Modal/ConfirmModal.vue index 147258849..8297e6d0f 100644 --- a/webapp/components/Modal/ConfirmModal.vue +++ b/webapp/components/Modal/ConfirmModal.vue @@ -77,7 +77,7 @@ export default { }, 500) }, 1500) } catch (err) { - this.success = false + this.isOpen = false } finally { this.loading = false } diff --git a/webapp/components/Modal/DisableModal.spec.js b/webapp/components/Modal/DisableModal.spec.js index 8bf796921..a1bc2046e 100644 --- a/webapp/components/Modal/DisableModal.spec.js +++ b/webapp/components/Modal/DisableModal.spec.js @@ -26,9 +26,7 @@ describe('DisableModal.vue', () => { $apollo: { mutate: jest .fn() - .mockResolvedValueOnce({ - enable: 'u4711', - }) + .mockResolvedValueOnce() .mockRejectedValue({ message: 'Not Authorised!', }), @@ -159,11 +157,13 @@ describe('DisableModal.vue', () => { expect(mocks.$apollo.mutate).toHaveBeenCalled() }) - it('passes id to mutation', () => { + it('passes parameters to mutation', () => { const calls = mocks.$apollo.mutate.mock.calls const [[{ variables }]] = calls - expect(variables).toEqual({ - id: 'u4711', + expect(variables).toMatchObject({ + resourceId: 'u4711', + disable: true, + closed: false, }) }) diff --git a/webapp/components/Modal/DisableModal.vue b/webapp/components/Modal/DisableModal.vue index 1e778ec68..d80ec0f55 100644 --- a/webapp/components/Modal/DisableModal.vue +++ b/webapp/components/Modal/DisableModal.vue @@ -54,11 +54,13 @@ export default { // await this.modalData.buttons.confirm.callback() await this.$apollo.mutate({ mutation: gql` - mutation($id: ID!) { - disable(id: $id) + mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) { + review(resourceId: $resourceId, disable: $disable, closed: $closed) { + disable + } } `, - variables: { id: this.id }, + variables: { resourceId: this.id, disable: true, closed: false }, }) this.$toast.success(this.$t('disable.success')) this.isOpen = false @@ -67,6 +69,7 @@ export default { }, 1000) } catch (err) { this.$toast.error(err.message) + this.isOpen = false } }, }, diff --git a/webapp/components/Modal/ReportModal.vue b/webapp/components/Modal/ReportModal.vue index 9b155e8b6..00fed2646 100644 --- a/webapp/components/Modal/ReportModal.vue +++ b/webapp/components/Modal/ReportModal.vue @@ -149,6 +149,7 @@ export default { default: this.$toast.error(err.message) } + this.isOpen = false this.loading = false }) }, diff --git a/webapp/components/ReleaseModal/ReleaseModal.spec.js b/webapp/components/ReleaseModal/ReleaseModal.spec.js index 22201a406..80e07ccce 100644 --- a/webapp/components/ReleaseModal/ReleaseModal.spec.js +++ b/webapp/components/ReleaseModal/ReleaseModal.spec.js @@ -27,7 +27,7 @@ describe('ReleaseModal.vue', () => { $apollo: { mutate: jest .fn() - .mockResolvedValueOnce({ enable: 'u4711' }) + .mockResolvedValueOnce() .mockRejectedValue({ message: 'Not Authorised!' }), }, location: { @@ -154,11 +154,13 @@ describe('ReleaseModal.vue', () => { expect(mocks.$apollo.mutate).toHaveBeenCalled() }) - it('passes id to mutation', () => { + it('passes parameters to mutation', () => { const calls = mocks.$apollo.mutate.mock.calls const [[{ variables }]] = calls - expect(variables).toEqual({ - id: 'u4711', + expect(variables).toMatchObject({ + resourceId: 'u4711', + disable: false, + closed: false, }) }) diff --git a/webapp/components/ReleaseModal/ReleaseModal.vue b/webapp/components/ReleaseModal/ReleaseModal.vue index dace3d665..82f800ddb 100644 --- a/webapp/components/ReleaseModal/ReleaseModal.vue +++ b/webapp/components/ReleaseModal/ReleaseModal.vue @@ -53,11 +53,13 @@ export default { // await this.modalData.buttons.confirm.callback() await this.$apollo.mutate({ mutation: gql` - mutation($id: ID!) { - enable(id: $id) + mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) { + review(resourceId: $resourceId, disable: $disable, closed: $closed) { + disable + } } `, - variables: { id: this.id }, + variables: { resourceId: this.id, disable: false, closed: false }, }) this.$toast.success(this.$t('release.success')) this.isOpen = false @@ -66,6 +68,7 @@ export default { }, 1000) } catch (err) { this.$toast.error(err.message) + this.isOpen = false } }, }, diff --git a/webapp/components/User/User.vue b/webapp/components/User/User.vue index 918d69cbd..1a5c7d8af 100644 --- a/webapp/components/User/User.vue +++ b/webapp/components/User/User.vue @@ -1,6 +1,6 @@