Merge pull request #1878 from Human-Connection/1707-refactor-db-reporting-with-specific-information

🍰 Refactor Database for Reporting with specific information
This commit is contained in:
mattwr18 2019-10-15 08:30:21 +02:00 committed by GitHub
commit 37bf37f39b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 498 additions and 148 deletions

View File

@ -122,7 +122,7 @@ const permissions = shield(
embed: allow,
Category: allow,
Tag: allow,
Report: isModerator,
reports: isModerator,
statistics: allow,
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),

View File

@ -57,11 +57,40 @@ const validateUpdatePost = async (resolve, root, args, context, info) => {
return validatePost(resolve, root, args, context, info)
}
const validateReport = async (resolve, root, args, context, info) => {
const { resourceId } = args
const { user, driver } = context
if (resourceId === user.id) throw new Error('You cannot report yourself!')
const session = driver.session()
const reportQueryRes = await session.run(
`
MATCH (:User {id:$submitterId})-[:REPORTED]->(resource {id:$resourceId})
RETURN labels(resource)[0] as label
`,
{
resourceId,
submitterId: user.id,
},
)
const [existingReportedResource] = reportQueryRes.records.map(record => {
return {
label: record.get('label'),
}
})
if (existingReportedResource)
throw new Error(
`You have already reported the ${existingReportedResource.label}, please only report the same ${existingReportedResource.label} once`,
)
return resolve(root, args, context, info)
}
export default {
Mutation: {
CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
CreatePost: validatePost,
UpdatePost: validateUpdatePost,
report: validateReport,
},
}

View File

@ -23,6 +23,7 @@ export default applyScalars(
'Location',
'SocialMedia',
'NOTIFIED',
'REPORTED',
],
// add 'User' here as soon as possible
},
@ -35,7 +36,6 @@ export default applyScalars(
'Notfication',
'Post',
'Comment',
'Report',
'Statistics',
'LoggedInUser',
'Location',
@ -43,6 +43,7 @@ export default applyScalars(
'User',
'EMOTED',
'NOTIFIED',
'REPORTED',
],
// add 'User' here as soon as possible
},

View File

@ -1,62 +1,76 @@
import uuid from 'uuid/v4'
export default {
Mutation: {
report: async (
_parent,
{ resourceId, reasonCategory, reasonDescription },
{ driver, _req, user },
_resolveInfo,
) => {
const reportId = uuid()
report: async (_parent, params, { driver, user }, _resolveInfo) => {
let createdRelationshipWithNestedAttributes
const { resourceId, reasonCategory, reasonDescription } = params
const session = driver.session()
const reportProperties = {
id: reportId,
createdAt: new Date().toISOString(),
reasonCategory,
reasonDescription,
}
const reportQueryRes = await session.run(
`
MATCH (u:User {id:$submitterId})-[:REPORTED]->(report)-[:REPORTED]->(resource {id: $resourceId})
RETURN labels(resource)[0] as label
`,
{
resourceId,
submitterId: user.id,
},
)
const [rep] = reportQueryRes.records.map(record => {
return {
label: record.get('label'),
}
const writeTxResultPromise = session.writeTransaction(async txc => {
const reportRelationshipTransactionResponse = 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
`,
{
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'),
}))
})
if (rep) {
throw new Error(rep.label)
try {
const txResult = await writeTxResultPromise
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
}
} finally {
session.close()
}
return createdRelationshipWithNestedAttributes
},
},
Query: {
reports: async (_parent, _params, { driver }, _resolveInfo) => {
const session = driver.session()
const res = await session.run(
`
MATCH (submitter:User {id: $userId})
MATCH (resource {id: $resourceId})
MATCH (submitter:User)-[report:REPORTED]->(resource)
WHERE resource:User OR resource:Comment OR resource:Post
CREATE (report:Report {reportProperties})
MERGE (resource)<-[:REPORTED]-(report)
MERGE (report)<-[:REPORTED]-(submitter)
RETURN report, submitter, resource, labels(resource)[0] as type
`,
{
resourceId,
userId: user.id,
reportProperties,
},
{},
)
session.close()
const [dbResponse] = res.records.map(r => {
const dbResponse = res.records.map(r => {
return {
report: r.get('report'),
submitter: r.get('submitter'),
@ -65,27 +79,33 @@ export default {
}
})
if (!dbResponse) return null
const { report, submitter, resource, type } = dbResponse
const response = {
...report.properties,
post: null,
comment: null,
user: null,
submitter: submitter.properties,
type,
}
switch (type) {
case 'Post':
response.post = resource.properties
break
case 'Comment':
response.comment = resource.properties
break
case 'User':
response.user = resource.properties
break
}
const 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 response
},

View File

@ -1,37 +1,73 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../../seed/factories'
import { host, login } from '../../jest/helpers'
import { neode } from '../../bootstrap/neo4j'
import { host, login, gql } from '../../jest/helpers'
import { getDriver, neode } from '../../bootstrap/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../.././server'
const factory = Factory()
const instance = neode()
const driver = getDriver()
describe('report', () => {
describe('report mutation', () => {
let reportMutation
let headers
let returnedObject
let client
let variables
let createPostVariables
let user
const categoryIds = ['cat9']
const action = () => {
reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
createdAt
reasonCategory
reasonDescription
type
submitter {
email
}
user {
name
}
post {
title
}
comment {
content
}
}
}
`
client = new GraphQLClient(host, {
headers,
})
return client.request(reportMutation, variables)
}
beforeEach(async () => {
returnedObject = '{ id }'
variables = {
resourceId: 'whatever',
reasonCategory: 'reason-category-dummy',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
headers = {}
user = await factory.create('User', {
id: 'u1',
role: 'user',
email: 'test@example.org',
password: '1234',
id: 'u1',
})
await factory.create('User', {
id: 'u2',
name: 'abusive-user',
role: 'user',
name: 'abusive-user',
email: 'abusive-user@example.org',
})
await instance.create('Category', {
@ -45,20 +81,6 @@ describe('report', () => {
await factory.cleanDatabase()
})
let client
const action = () => {
// because of the template `${returnedObject}` the 'gql' tag from 'jest/helpers' is not working here
reportMutation = `
mutation($resourceId: ID!, $reasonCategory: String!, $reasonDescription: String!) {
report(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) ${returnedObject}
}
`
client = new GraphQLClient(host, {
headers,
})
return client.request(reportMutation, variables)
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
await expect(action()).rejects.toThrow('Not Authorised')
@ -91,8 +113,7 @@ describe('report', () => {
})
it('returns type "User"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
type: 'User',
},
@ -100,8 +121,7 @@ describe('report', () => {
})
it('returns resource in user attribute', async () => {
returnedObject = '{ user { name } }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
user: {
name: 'abusive-user',
@ -111,8 +131,7 @@ describe('report', () => {
})
it('returns the submitter', async () => {
returnedObject = '{ submitter { email } }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
submitter: {
email: 'test@example.org',
@ -122,36 +141,41 @@ describe('report', () => {
})
it('returns a date', async () => {
returnedObject = '{ createdAt }'
await expect(action()).resolves.toEqual(
expect.objectContaining({
report: {
createdAt: expect.any(String),
},
}),
)
await expect(action()).resolves.toMatchObject({
report: {
createdAt: expect.any(String),
},
})
})
it('returns the reason category', async () => {
variables = {
...variables,
reasonCategory: 'my-category',
reasonCategory: 'criminal_behavior_violation_german_law',
}
returnedObject = '{ reasonCategory }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
reasonCategory: 'my-category',
reasonCategory: 'criminal_behavior_violation_german_law',
},
})
})
it('gives an error if the reason category is not in enum "ReasonCategory"', async () => {
variables = {
...variables,
reasonCategory: 'my_category',
}
await expect(action()).rejects.toThrow(
'got invalid value "my_category"; Expected type ReasonCategory',
)
})
it('returns the reason description', async () => {
variables = {
...variables,
reasonDescription: 'My reason!',
}
returnedObject = '{ reasonDescription }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
reasonDescription: 'My reason!',
},
@ -163,8 +187,7 @@ describe('report', () => {
...variables,
reasonDescription: 'My reason <sanitize></sanitize>!',
}
returnedObject = '{ reasonDescription }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
reasonDescription: 'My reason !',
},
@ -187,8 +210,7 @@ describe('report', () => {
})
it('returns type "Post"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
type: 'Post',
},
@ -196,8 +218,7 @@ describe('report', () => {
})
it('returns resource in post attribute', async () => {
returnedObject = '{ post { title } }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
post: {
title: 'Matt and Robert having a pair-programming',
@ -207,8 +228,7 @@ describe('report', () => {
})
it('returns null in user attribute', async () => {
returnedObject = '{ user { name } }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
user: null,
},
@ -241,8 +261,7 @@ describe('report', () => {
})
it('returns type "Comment"', async () => {
returnedObject = '{ type }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
type: 'Comment',
},
@ -250,8 +269,7 @@ describe('report', () => {
})
it('returns resource in comment attribute', async () => {
returnedObject = '{ comment { content } }'
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: {
comment: {
content: 'Robert getting tired.',
@ -276,7 +294,7 @@ describe('report', () => {
})
it('returns null', async () => {
await expect(action()).resolves.toEqual({
await expect(action()).resolves.toMatchObject({
report: null,
})
})
@ -287,3 +305,217 @@ describe('report', () => {
})
})
})
describe('reports query', () => {
let query, mutate, authenticatedUser, moderator, user, author
const categoryIds = ['cat9']
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
type
}
}
`
const reportsQuery = gql`
query {
reports(orderBy: createdAt_desc) {
createdAt
reasonCategory
reasonDescription
submitter {
id
}
type
user {
id
}
post {
id
}
comment {
id
}
}
}
`
beforeAll(async () => {
await factory.cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
authenticatedUser = null
moderator = await factory.create('User', {
id: 'mod1',
role: 'moderator',
email: 'moderator@example.org',
password: '1234',
})
user = await factory.create('User', {
id: 'user1',
role: 'user',
email: 'test@example.org',
password: '1234',
})
author = await factory.create('User', {
id: 'auth1',
role: 'user',
name: 'abusive-user',
email: 'abusive-user@example.org',
})
await instance.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
await Promise.all([
factory.create('Post', {
author,
id: 'p1',
categoryIds,
content: 'Interesting Knowledge',
}),
factory.create('Post', {
author: moderator,
id: 'p2',
categoryIds,
content: 'More things to do …',
}),
factory.create('Post', {
author: user,
id: 'p3',
categoryIds,
content: 'I am at school …',
}),
])
await Promise.all([
factory.create('Comment', {
author: user,
id: 'c1',
postId: 'p1',
}),
])
authenticatedUser = await user.toJson()
await Promise.all([
mutate({
mutation: reportMutation,
variables: {
resourceId: 'p1',
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
},
}),
mutate({
mutation: reportMutation,
variables: {
resourceId: 'c1',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
},
}),
mutate({
mutation: reportMutation,
variables: {
resourceId: 'auth1',
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
},
}),
])
authenticatedUser = null
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
expect(query({ query: reportsQuery })).resolves.toMatchObject({
data: { reports: null },
errors: [{ message: 'Not Authorised!' }],
})
})
it('role "user" gets no reports', async () => {
authenticatedUser = await user.toJson()
expect(query({ query: reportsQuery })).resolves.toMatchObject({
data: { reports: null },
errors: [{ message: 'Not Authorised!' }],
})
})
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({
createdAt: expect.any(String),
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
submitter: expect.objectContaining({
id: 'user1',
}),
type: 'User',
user: expect.objectContaining({
id: 'auth1',
}),
post: null,
comment: null,
}),
expect.objectContaining({
createdAt: expect.any(String),
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
submitter: expect.objectContaining({
id: 'user1',
}),
type: 'Post',
user: null,
post: expect.objectContaining({
id: 'p1',
}),
comment: null,
}),
expect.objectContaining({
createdAt: expect.any(String),
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
submitter: expect.objectContaining({
id: 'user1',
}),
type: 'Comment',
user: null,
post: null,
comment: expect.objectContaining({
id: 'c1',
}),
}),
]),
}
authenticatedUser = await moderator.toJson()
const { data } = await query({ query: reportsQuery })
expect(data).toEqual(expected)
})
})
})

View File

@ -24,7 +24,6 @@ type Mutation {
changePassword(oldPassword: String!, newPassword: String!): String!
requestPasswordReset(email: String!): Boolean!
resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean!
report(resourceId: ID!, reasonCategory: String!, reasonDescription: String!): Report
disable(id: ID!): ID
enable(id: ID!): ID
# Shout the given Type and ID
@ -35,19 +34,6 @@ type Mutation {
unfollowUser(id: ID!): User
}
type Report {
id: ID!
createdAt: String!
reasonCategory: String!
reasonDescription: String!
submitter: User @relation(name: "REPORTED", direction: "IN")
type: String!
@cypher(statement: "MATCH (resource)<-[:REPORTED]-(this) RETURN labels(resource)[0]")
comment: Comment @relation(name: "REPORTED", direction: "OUT")
post: Post @relation(name: "REPORTED", direction: "OUT")
user: User @relation(name: "REPORTED", direction: "OUT")
}
enum Deletable {
Post
Comment

View File

@ -0,0 +1,42 @@
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/components/Modal/ReportModal.vue"
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_desc
}
type Query {
reports(orderBy: ReportOrdering): [REPORTED]
}
type Mutation {
report(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): REPORTED
}

View File

@ -649,13 +649,13 @@ import { gql } from '../jest/helpers'
// There is no error logged or the 'try' fails if this mutation is wrong. Why?
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: String!, $reasonDescription: String!) {
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
type
}
}
`

View File

@ -127,9 +127,9 @@ Given('somebody reported the following posts:', table => {
cy.factory()
.create('User', submitter)
.authenticateAs(submitter)
.mutate(`mutation($resourceId: ID!, $reasonCategory: String!, $reasonDescription: String!) {
.mutate(`mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(resourceId: $resourceId, reasonCategory: $reasonCategory, reasonDescription: $reasonDescription) {
id
type
}
}`, {
resourceId,

View File

@ -0,0 +1,26 @@
#!/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! createdAt is when the database manipulation happened.'
RETURN reported;
" | cypher-shell

View File

@ -62,6 +62,7 @@ export default {
id: { type: String, required: true },
},
data() {
// this list equals to enums in GraphQL schema file "backend/src/schema/types/type/REPORTED.gql"
let valuesReasonCategoryOptions = [
'discrimination_etc',
'pornographic_content_links',

View File

@ -1,10 +1,10 @@
import gql from 'graphql-tag'
export const reportListQuery = () => {
// no limit vor the moment like before: "reports(first: 20, orderBy: createdAt_desc)"
return gql`
query {
Report(first: 20, orderBy: createdAt_desc) {
id
reports(orderBy: createdAt_desc) {
createdAt
reasonCategory
reasonDescription
@ -83,13 +83,13 @@ export const reportListQuery = () => {
export const reportMutation = () => {
return gql`
mutation($resourceId: ID!, $reasonCategory: String!, $reasonDescription: String!) {
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
report(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
type
}
}
`

View File

@ -431,6 +431,7 @@
"name": "Meldungen",
"reasonCategory": "Kategorie",
"reasonDescription": "Beschreibung",
"createdAt": "Datum",
"submitter": "Gemeldet von",
"disabledBy": "Deaktiviert von"
}

View File

@ -432,6 +432,7 @@
"name": "Reports",
"reasonCategory": "Category",
"reasonDescription": "Description",
"createdAt": "Date",
"submitter": "Reported by",
"disabledBy": "Disabled by"
}

View File

@ -1,7 +1,7 @@
<template>
<ds-card space="small">
<ds-heading tag="h3">{{ $t('moderation.reports.name') }}</ds-heading>
<ds-table v-if="Report && Report.length" :data="Report" :fields="fields" condensed>
<ds-table v-if="reports && reports.length" :data="reports" :fields="fields" condensed>
<!-- Icon -->
<template slot="type" slot-scope="scope">
<ds-text color="soft">
@ -82,6 +82,14 @@
{{ scope.row.submitter.name }}
</nuxt-link>
</template>
<!-- createdAt -->
<template slot="createdAt" slot-scope="scope">
<ds-text size="small">
<client-only>
<hc-relative-date-time :date-time="scope.row.createdAt" />
</client-only>
</ds-text>
</template>
<!-- disabledBy -->
<template slot="disabledBy" slot-scope="scope">
<nuxt-link
@ -123,15 +131,17 @@
<script>
import HcEmpty from '~/components/Empty.vue'
import HcRelativeDateTime from '~/components/RelativeDateTime'
import { reportListQuery } from '~/graphql/Moderation.js'
export default {
components: {
HcEmpty,
HcRelativeDateTime,
},
data() {
return {
Report: [],
reports: [],
}
},
computed: {
@ -142,13 +152,14 @@ export default {
reasonCategory: this.$t('moderation.reports.reasonCategory'),
reasonDescription: this.$t('moderation.reports.reasonDescription'),
submitter: this.$t('moderation.reports.submitter'),
createdAt: this.$t('moderation.reports.createdAt'),
disabledBy: this.$t('moderation.reports.disabledBy'),
// actions: ' '
}
},
},
apollo: {
Report: {
reports: {
query: reportListQuery(),
fetchPolicy: 'cache-and-network',
},