Merge pull request #2433 from Human-Connection/2412-favor-transaction-function

Favor transaction functions
This commit is contained in:
mattwr18 2019-12-13 13:20:43 +01:00 committed by GitHub
commit 56c5f4a384
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 961 additions and 687 deletions

View File

@ -11,27 +11,28 @@ export default async (driver, authorizationHeader) => {
} catch (err) {
return null
}
const query = `
MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1
`
const session = driver.session()
let result
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserLastActiveTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1
`,
{ id },
)
return updateUserLastActiveTransactionResponse.records.map(record => record.get('user'))
})
try {
result = await session.run(query, { id })
const [currentUser] = await writeTxResultPromise
if (!currentUser) return null
return {
token,
...currentUser,
}
} finally {
session.close()
}
const [currentUser] = await result.records.map(record => {
return record.get('user')
})
if (!currentUser) return null
return {
token,
...currentUser,
}
}

View File

@ -2,30 +2,23 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
// and no new Hashtags and relations will be created.
const cypherDeletePreviousRelations = `
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations
RETURN p, t
`
const cypherCreateNewTagsAndRelations = `
MATCH (p: Post { id: $postId})
UNWIND $hashtags AS tagName
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t)
RETURN p, t
`
const session = context.driver.session()
try {
await session.run(cypherDeletePreviousRelations, {
postId,
})
await session.run(cypherCreateNewTagsAndRelations, {
postId,
hashtags,
await session.writeTransaction(txc => {
return txc.run(
`
MATCH (post:Post { id: $postId})
OPTIONAL MATCH (post)-[previousRelations:TAGGED]->(tag:Tag)
DELETE previousRelations
WITH post
UNWIND $hashtags AS tagName
MERGE (tag:Tag {id: tagName, disabled: false, deleted: false })
MERGE (post)-[:TAGGED]->(tag)
RETURN post, tag
`,
{ postId, hashtags },
)
})
} finally {
session.close()

View File

@ -7,7 +7,7 @@ import sluggify from './sluggifyMiddleware'
import excerpt from './excerptMiddleware'
import xss from './xssMiddleware'
import permissions from './permissionsMiddleware'
import user from './userMiddleware'
import user from './user/userMiddleware'
import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation/validationMiddleware'

View File

@ -38,7 +38,7 @@ const createLocation = async (session, mapboxData) => {
lng: mapboxData.center && mapboxData.center.length ? mapboxData.center[1] : null,
}
let query =
let mutation =
'MERGE (l:Location {id: $id}) ' +
'SET l.name = $nameEN, ' +
'l.nameEN = $nameEN, ' +
@ -53,19 +53,23 @@ const createLocation = async (session, mapboxData) => {
'l.type = $type'
if (data.lat && data.lng) {
query += ', l.lat = $lat, l.lng = $lng'
mutation += ', l.lat = $lat, l.lng = $lng'
}
query += ' RETURN l.id'
mutation += ' RETURN l.id'
await session.run(query, data)
session.close()
try {
await session.writeTransaction(transaction => {
return transaction.run(mutation, data)
})
} finally {
session.close()
}
}
const createOrUpdateLocations = async (userId, locationName, driver) => {
if (isEmpty(locationName)) {
return
}
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
@ -106,33 +110,44 @@ const createOrUpdateLocations = async (userId, locationName, driver) => {
if (data.context) {
await asyncForEach(data.context, async ctx => {
await createLocation(session, ctx)
await session.run(
'MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) ' +
'MERGE (child)<-[:IS_IN]-(parent) ' +
'RETURN child.id, parent.id',
{
parentId: parent.id,
childId: ctx.id,
},
)
parent = ctx
try {
await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
} finally {
session.close()
}
})
}
// delete all current locations from user
await session.run('MATCH (u:User {id: $userId})-[r:IS_IN]->(l:Location) DETACH DELETE r', {
userId: userId,
})
// connect user with location
await session.run(
'MATCH (u:User {id: $userId}), (l:Location {id: $locationId}) MERGE (u)-[:IS_IN]->(l) RETURN l.id, u.id',
{
userId: userId,
locationId: data.id,
},
)
session.close()
// delete all current locations from user and add new location
try {
await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (user:User {id: $userId})-[relationship:IS_IN]->(location:Location)
DETACH DELETE relationship
WITH user
MATCH (location:Location {id: $locationId})
MERGE (user)-[:IS_IN]->(location)
RETURN location.id, user.id
`,
{ userId: userId, locationId: data.id },
)
})
} finally {
session.close()
}
}
export default createOrUpdateLocations

View File

@ -1,164 +1,121 @@
import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
const postAuthorOfComment = async (comment, { context }) => {
const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id }
`
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post && idsOfUsers && idsOfUsers.length)
await notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const { content } = args
let idsOfUsers = extractMentionedUsers(content)
const comment = await resolve(root, args, context, resolveInfo)
const [postAuthor] = await postAuthorOfComment(comment.id, { context })
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
if (idsOfUsers && idsOfUsers.length)
await notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
if (context.user.id !== postAuthor.id)
await notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context)
return comment
}
const postAuthorOfComment = async (commentId, { context }) => {
const session = context.driver.session()
let result
let postAuthorId
try {
result = await session.run(cypherFindUser, {
commentId: comment.id,
postAuthorId = await session.readTransaction(transaction => {
return transaction.run(
`
MATCH (author:User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN author { .id } as authorId
`,
{ commentId },
)
})
return postAuthorId.records.map(record => record.get('authorId'))
} finally {
session.close()
}
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
return postAuthor
}
const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return
// Checked here, because it does not go through GraphQL checks at all in this file.
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) {
throw new Error('Notification reason is not allowed!')
}
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
let cypher
const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
await validateNotifyUsers(label, reason)
let mentionedCypher
switch (reason) {
case 'mentioned_in_post': {
cypher = `
mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`
break
}
case 'mentioned_in_comment': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`
break
}
case 'commented_on_post': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
mentionedCypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
`
break
}
}
mentionedCypher += `
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`
const session = context.driver.session()
try {
await session.run(cypher, {
id,
idsOfUsers,
reason,
await session.writeTransaction(transaction => {
return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
})
} finally {
session.close()
}
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => {
await validateNotifyUsers(label, reason)
const session = context.driver.session()
const post = await resolve(root, args, context, resolveInfo)
if (post) {
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
}
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
let idsOfUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo)
if (comment) {
const postAuthor = await postAuthorOfComment(comment, { context })
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
}
return comment
}
const handleCreateComment = async (resolve, root, args, context, resolveInfo) => {
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
if (comment) {
const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id }
`
const session = context.driver.session()
let result
try {
result = await session.run(cypherFindUser, {
commentId: comment.id,
})
} finally {
session.close()
}
const [postAuthor] = await result.records.map(record => {
return record.get('user')
try {
await session.writeTransaction(async transaction => {
await transaction.run(
`
MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`,
{ commentId, postAuthorId, reason },
)
})
if (context.user.id !== postAuthor.id) {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context)
}
} finally {
session.close()
}
return comment
}
export default {
Mutation: {
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment,
CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment,
},
}

View File

@ -4,11 +4,7 @@ import { createTestClient } from 'apollo-server-testing'
import { getNeode, getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
let server
let query
let mutate
let notifiedUser
let authenticatedUser
let server, query, mutate, notifiedUser, authenticatedUser
const factory = Factory()
const driver = getDriver()
const neode = getNeode()
@ -39,7 +35,8 @@ const createCommentMutation = gql`
}
`
beforeAll(() => {
beforeAll(async () => {
await factory.cleanDatabase()
const createServerResult = createServer({
context: () => {
return {
@ -173,7 +170,6 @@ describe('notifications', () => {
],
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -190,7 +186,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -214,7 +210,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -265,7 +261,7 @@ describe('notifications', () => {
],
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -409,7 +405,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -467,7 +463,7 @@ describe('notifications', () => {
],
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -501,7 +497,7 @@ describe('notifications', () => {
],
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -532,7 +528,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,

View File

@ -47,17 +47,18 @@ const isAuthor = rule({
if (!user) return false
const { id: resourceId } = args
const session = driver.session()
try {
const result = await session.run(
const authorReadTxPromise = session.readTransaction(async transaction => {
const authorTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
RETURN author
`,
MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
RETURN author
`,
{ resourceId, userId: user.id },
)
const [author] = result.records.map(record => {
return record.get('author')
})
return authorTransactionResponse.records.map(record => record.get('author'))
})
try {
const [author] = await authorReadTxPromise
return !!author
} finally {
session.close()

View File

@ -4,10 +4,16 @@ const isUniqueFor = (context, type) => {
return async slug => {
const session = context.driver.session()
try {
const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, {
slug,
const existingSlug = await session.readTransaction(transaction => {
return transaction.run(
`
MATCH(p:${type} {slug: $slug })
RETURN p.slug
`,
{ slug },
)
})
return response.records.length === 0
return existingSlug.records.length === 0
} finally {
session.close()
}

View File

@ -1,10 +1,10 @@
import createOrUpdateLocations from './nodes/locations'
import createOrUpdateLocations from '../nodes/locations'
export default {
Mutation: {
SignupVerification: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver)
await createOrUpdateLocations(result.id, args.locationName, context.driver)
return result
},
UpdateUser: async (resolve, root, args, context, info) => {

View File

@ -0,0 +1,213 @@
import { gql } from '../../helpers/jest'
import Factory from '../../seed/factories'
import { getNeode, getDriver } from '../../bootstrap/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
const factory = Factory()
const neode = getNeode()
const driver = getDriver()
let authenticatedUser, mutate, variables
const signupVerificationMutation = gql`
mutation(
$name: String!
$password: String!
$email: String!
$nonce: String!
$termsAndConditionsAgreedVersion: String!
$locationName: String
) {
SignupVerification(
name: $name
password: $password
email: $email
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) {
locationName
}
}
`
const updateUserMutation = gql`
mutation($id: ID!, $name: String!, $locationName: String) {
UpdateUser(id: $id, name: $name, locationName: $locationName) {
locationName
}
}
`
let newlyCreatedNodesWithLocales = [
{
city: {
lng: 41.1534,
nameES: 'Hamburg',
nameFR: 'Hamburg',
nameIT: 'Hamburg',
nameEN: 'Hamburg',
type: 'place',
namePT: 'Hamburg',
nameRU: 'Хамбург',
nameDE: 'Hamburg',
nameNL: 'Hamburg',
name: 'Hamburg',
namePL: 'Hamburg',
id: 'place.5977106083398860',
lat: -74.5763,
},
state: {
namePT: 'Nova Jérsia',
nameRU: 'Нью-Джерси',
nameDE: 'New Jersey',
nameNL: 'New Jersey',
nameES: 'Nueva Jersey',
name: 'New Jersey',
namePL: 'New Jersey',
nameFR: 'New Jersey',
nameIT: 'New Jersey',
id: 'region.14919479731700330',
nameEN: 'New Jersey',
type: 'region',
},
country: {
namePT: 'Estados Unidos',
nameRU: 'Соединённые Штаты Америки',
nameDE: 'Vereinigte Staaten',
nameNL: 'Verenigde Staten van Amerika',
nameES: 'Estados Unidos',
namePL: 'Stany Zjednoczone',
name: 'United States of America',
nameFR: 'États-Unis',
nameIT: "Stati Uniti d'America",
id: 'country.9053006287256050',
nameEN: 'United States of America',
type: 'country',
},
},
]
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
user: authenticatedUser,
neode,
driver,
}
},
})
mutate = createTestClient(server).mutate
})
beforeEach(() => {
variables = {}
authenticatedUser = null
})
afterEach(() => {
factory.cleanDatabase()
})
describe('userMiddleware', () => {
describe('SignupVerification', () => {
beforeEach(async () => {
variables = {
...variables,
name: 'John Doe',
password: '123',
email: 'john@example.org',
nonce: '123456',
termsAndConditionsAgreedVersion: '0.1.0',
locationName: 'Hamburg, New Jersey, United States of America',
}
const args = {
email: 'john@example.org',
nonce: '123456',
}
await neode.model('EmailAddress').create(args)
})
it('creates a Location node with localised city/state/country names', async () => {
await mutate({ mutation: signupVerificationMutation, variables })
const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city, state, country`,
)
expect(
locations.records.map(record => {
return {
city: record.get('city').properties,
state: record.get('state').properties,
country: record.get('country').properties,
}
}),
).toEqual(newlyCreatedNodesWithLocales)
})
})
describe('UpdateUser', () => {
let user, userParams
beforeEach(async () => {
newlyCreatedNodesWithLocales = [
{
city: {
lng: 53.55,
nameES: 'Hamburgo',
nameFR: 'Hambourg',
nameIT: 'Amburgo',
nameEN: 'Hamburg',
type: 'region',
namePT: 'Hamburgo',
nameRU: 'Гамбург',
nameDE: 'Hamburg',
nameNL: 'Hamburg',
namePL: 'Hamburg',
name: 'Hamburg',
id: 'region.10793468240398860',
lat: 10,
},
country: {
namePT: 'Alemanha',
nameRU: 'Германия',
nameDE: 'Deutschland',
nameNL: 'Duitsland',
nameES: 'Alemania',
name: 'Germany',
namePL: 'Niemcy',
nameFR: 'Allemagne',
nameIT: 'Germania',
id: 'country.10743216036480410',
nameEN: 'Germany',
type: 'country',
},
},
]
userParams = {
id: 'updating-user',
}
user = await factory.create('User', userParams)
authenticatedUser = await user.toJson()
})
it('creates a Location node with localised city/state/country names', async () => {
variables = {
...variables,
id: 'updating-user',
name: 'Updating user',
locationName: 'Hamburg, Germany',
}
await mutate({ mutation: updateUserMutation, variables })
const locations = await neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(country:Location) return city, country`,
)
expect(
locations.records.map(record => {
return {
city: record.get('city').properties,
country: record.get('country').properties,
}
}),
).toEqual(newlyCreatedNodesWithLocales)
})
})
})

View File

@ -4,7 +4,7 @@ const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three'
const USERNAME_MIN_LENGTH = 3
const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args
@ -14,14 +14,15 @@ const validateCreateComment = async (resolve, root, args, context, info) => {
}
const session = context.driver.session()
try {
const postQueryRes = await session.run(
`
MATCH (post:Post {id: $postId})
RETURN post`,
{
postId,
},
)
const postQueryRes = await session.readTransaction(transaction => {
return transaction.run(
`
MATCH (post:Post {id: $postId})
RETURN post
`,
{ postId },
)
})
const [post] = postQueryRes.records.map(record => {
return record.get('post')
})
@ -72,8 +73,8 @@ const validateReview = async (resolve, root, args, context, info) => {
const { user, driver } = context
if (resourceId === user.id) throw new Error('You cannot review yourself!')
const session = driver.session()
const reportReadTxPromise = session.writeTransaction(async txc => {
const validateReviewTransactionResponse = await txc.run(
const reportReadTxPromise = session.readTransaction(async transaction => {
const validateReviewTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Post OR resource:Comment
@ -115,12 +116,31 @@ const validateReview = async (resolve, root, args, context, info) => {
return resolve(root, args, context, info)
}
export const validateNotifyUsers = async (label, reason) => {
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) throw new Error('Notification reason is not allowed!')
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
}
const validateUpdateUser = async (resolve, root, params, context, info) => {
const { name } = params
if (typeof name === 'string' && name.trim().length < USERNAME_MIN_LENGTH)
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} character long!`)
return resolve(root, params, context, info)
}
export default {
Mutation: {
CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment,
CreatePost: validatePost,
UpdatePost: validateUpdatePost,
UpdateUser: validateUpdateUser,
fileReport: validateReport,
review: validateReview,
},

View File

@ -71,6 +71,14 @@ const reviewMutation = gql`
}
}
`
const updateUserMutation = gql`
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
}
}
`
beforeAll(() => {
const { server } = createServer({
context: () => {
@ -397,4 +405,33 @@ describe('validateReview', () => {
})
})
})
describe('validateUpdateUser', () => {
let userParams, variables, updatingUser
beforeEach(async () => {
userParams = {
id: 'updating-user',
name: 'John Doe',
}
variables = {
id: 'updating-user',
name: 'John Doughnut',
}
updatingUser = await factory.create('User', userParams)
authenticatedUser = await updatingUser.toJson()
})
it('with name too short', async () => {
variables = {
...variables,
name: ' ',
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: null },
errors: [{ message: 'Username must be at least 3 character long!' }],
})
})
})
})

View File

@ -5,6 +5,7 @@ export default {
Mutation: {
CreateComment: async (object, params, context, resolveInfo) => {
const { postId } = params
const { user, driver } = context
// Adding relationship from comment to post by passing in the postId,
// but we do not want to create the comment with postId as an attribute
// because we use relationships for this. So, we are deleting it from params
@ -12,26 +13,28 @@ export default {
delete params.postId
params.id = params.id || uuid()
const session = context.driver.session()
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const createCommentTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId})
MATCH (author:User {id: $userId})
WITH post, author
CREATE (comment:Comment {params})
SET comment.createdAt = toString(datetime())
SET comment.updatedAt = toString(datetime())
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN comment
`,
{ userId: user.id, postId, params },
)
return createCommentTransactionResponse.records.map(
record => record.get('comment').properties,
)
})
try {
const createCommentCypher = `
MATCH (post:Post {id: $postId})
MATCH (author:User {id: $userId})
WITH post, author
CREATE (comment:Comment {params})
SET comment.createdAt = toString(datetime())
SET comment.updatedAt = toString(datetime())
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)
RETURN comment
`
const transactionRes = await session.run(createCommentCypher, {
userId: context.user.id,
postId,
params,
})
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
const [comment] = await writeTxResultPromise
return comment
} finally {
session.close()
@ -39,15 +42,22 @@ export default {
},
UpdateComment: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateCommentTransactionResponse = await transaction.run(
`
MATCH (comment:Comment {id: $params.id})
SET comment += $params
SET comment.updatedAt = toString(datetime())
RETURN comment
`,
{ params },
)
return updateCommentTransactionResponse.records.map(
record => record.get('comment').properties,
)
})
try {
const updateCommentCypher = `
MATCH (comment:Comment {id: $params.id})
SET comment += $params
SET comment.updatedAt = toString(datetime())
RETURN comment
`
const transactionRes = await session.run(updateCommentCypher, { params })
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
const [comment] = await writeTxResultPromise
return comment
} finally {
session.close()
@ -55,18 +65,23 @@ export default {
},
DeleteComment: async (_parent, args, context, _resolveInfo) => {
const session = context.driver.session()
try {
const transactionRes = await session.run(
`
MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE
SET comment.content = 'UNAVAILABLE'
SET comment.contentExcerpt = 'UNAVAILABLE'
RETURN comment
`,
const writeTxResultPromise = session.writeTransaction(async transaction => {
const deleteCommentTransactionResponse = await transaction.run(
`
MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE
SET comment.content = 'UNAVAILABLE'
SET comment.contentExcerpt = 'UNAVAILABLE'
RETURN comment
`,
{ commentId: args.id },
)
const [comment] = transactionRes.records.map(record => record.get('comment').properties)
return deleteCommentTransactionResponse.records.map(
record => record.get('comment').properties,
)
})
try {
const [comment] = await writeTxResultPromise
return comment
} finally {
session.close()

View File

@ -10,7 +10,8 @@ const factory = Factory()
let variables, mutate, authenticatedUser, commentAuthor, newlyCreatedComment
beforeAll(() => {
beforeAll(async () => {
await factory.cleanDatabase()
const { server } = createServer({
context: () => {
return {
@ -19,8 +20,7 @@ beforeAll(() => {
}
},
})
const client = createTestClient(server)
mutate = client.mutate
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
@ -100,6 +100,7 @@ describe('CreateComment', () => {
await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject(
{
data: { CreateComment: { content: "I'm authorised to comment" } },
errors: undefined,
},
)
})
@ -108,6 +109,7 @@ describe('CreateComment', () => {
await expect(mutate({ mutation: createCommentMutation, variables })).resolves.toMatchObject(
{
data: { CreateComment: { author: { name: 'Author' } } },
errors: undefined,
},
)
})
@ -157,6 +159,7 @@ describe('UpdateComment', () => {
it('updates the comment', async () => {
const expected = {
data: { UpdateComment: { id: 'c456', content: 'The comment is updated' } },
errors: undefined,
}
await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject(
expected,
@ -172,6 +175,7 @@ describe('UpdateComment', () => {
createdAt: expect.any(String),
},
},
errors: undefined,
}
await expect(mutate({ mutation: updateCommentMutation, variables })).resolves.toMatchObject(
expected,

View File

@ -5,24 +5,29 @@ export default async function createPasswordReset(options) {
const normalizedEmail = normalizeEmail(email)
const session = driver.session()
try {
const cypher = `
MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr)
RETURN e, pr, u
`
const transactionRes = await session.run(cypher, {
issuedAt: issuedAt.toISOString(),
nonce,
email: normalizedEmail,
const createPasswordResetTxPromise = session.writeTransaction(async transaction => {
const createPasswordResetTransactionResponse = await transaction.run(
`
MATCH (user:User)-[:PRIMARY_EMAIL]->(email:EmailAddress {email:$email})
CREATE(passwordReset:PasswordReset {nonce: $nonce, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (user)-[:REQUESTED]->(passwordReset)
RETURN email, passwordReset, user
`,
{
issuedAt: issuedAt.toISOString(),
nonce,
email: normalizedEmail,
},
)
return createPasswordResetTransactionResponse.records.map(record => {
const { email } = record.get('email').properties
const { nonce } = record.get('passwordReset').properties
const { name } = record.get('user').properties
return { email, nonce, name }
})
})
const records = transactionRes.records.map(record => {
const { email } = record.get('e').properties
const { nonce } = record.get('pr').properties
const { name } = record.get('u').properties
return { email, nonce, name }
})
return records[0] || {}
const [records] = await createPasswordResetTxPromise
return records || {}
} finally {
session.close()
}

View File

@ -1,35 +0,0 @@
import createPasswordReset from './createPasswordReset'
describe('createPasswordReset', () => {
const issuedAt = new Date()
const nonce = 'abcdef'
describe('email lookup', () => {
let driver
let mockSession
beforeEach(() => {
mockSession = {
close() {},
run: jest.fn().mockReturnValue({
records: {
map: jest.fn(() => []),
},
}),
}
driver = { session: () => mockSession }
})
it('lowercases email address', async () => {
const email = 'stRaNGeCaSiNG@ExAmplE.ORG'
await createPasswordReset({ driver, email, issuedAt, nonce })
expect(mockSession.run.mock.calls).toEqual([
[
expect.any(String),
expect.objectContaining({
email: 'strangecasing@example.org',
}),
],
])
})
})
})

View File

@ -1,25 +1,29 @@
import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail({ args, context }) {
const cypher = `
MATCH (email:EmailAddress {email: $email})
OPTIONAL MATCH (email)-[:BELONGS_TO]-(user)
RETURN email, user
`
let transactionRes
const session = context.driver.session()
try {
transactionRes = await session.run(cypher, { email: args.email })
const existingEmailAddressTxPromise = session.writeTransaction(async transaction => {
const existingEmailAddressTransactionResponse = await transaction.run(
`
MATCH (email:EmailAddress {email: $email})
OPTIONAL MATCH (email)-[:BELONGS_TO]-(user)
RETURN email, user
`,
{ email: args.email },
)
return existingEmailAddressTransactionResponse.records.map(record => {
return {
alreadyExistingEmail: record.get('email').properties,
user: record.get('user') && record.get('user').properties,
}
})
})
const [emailBelongsToUser] = await existingEmailAddressTxPromise
const { alreadyExistingEmail, user } = emailBelongsToUser || {}
if (user) throw new UserInputError('A user account with this email already exists.')
return alreadyExistingEmail
} finally {
session.close()
}
const [result] = transactionRes.records.map(record => {
return {
alreadyExistingEmail: record.get('email').properties,
user: record.get('user') && record.get('user').properties,
}
})
const { alreadyExistingEmail, user } = result || {}
if (user) throw new UserInputError('A user account with this email already exists.')
return alreadyExistingEmail
}

View File

@ -76,16 +76,21 @@ export default {
markAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const markNotificationAsReadTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
RETURN resource, notification, user
`,
{ resourceId: args.id, id: currentUser.id },
)
log(markNotificationAsReadTransactionResponse)
return markNotificationAsReadTransactionResponse.records.map(transformReturnType)
})
try {
const cypher = `
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
RETURN resource, notification, user
`
const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id })
log(result)
const notifications = await result.records.map(transformReturnType)
return notifications[0]
const [notifications] = await writeTxResultPromise
return notifications
} finally {
session.close()
}

View File

@ -12,25 +12,29 @@ export default {
const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = `
MATCH (pr:PasswordReset {nonce: $nonce})
MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime()
SET u.encryptedPassword = $encryptedNewPassword
RETURN pr
`
const session = driver.session()
try {
const transactionRes = await session.run(cypher, {
stillValid,
email,
nonce,
encryptedNewPassword,
const passwordResetTxPromise = session.writeTransaction(async transaction => {
const passwordResetTransactionResponse = await transaction.run(
`
MATCH (passwordReset:PasswordReset {nonce: $nonce})
MATCH (email:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(user:User)-[:REQUESTED]->(passwordReset)
WHERE duration.between(passwordReset.issuedAt, datetime()).days <= 0 AND passwordReset.usedAt IS NULL
SET passwordReset.usedAt = datetime()
SET user.encryptedPassword = $encryptedNewPassword
RETURN passwordReset
`,
{
stillValid,
email,
nonce,
encryptedNewPassword,
},
)
return passwordResetTransactionResponse.records.map(record => record.get('passwordReset'))
})
const [reset] = transactionRes.records.map(record => record.get('pr'))
const response = !!(reset && reset.properties.usedAt)
return response
const [reset] = await passwordResetTxPromise
return !!(reset && reset.properties.usedAt)
} finally {
session.close()
}

View File

@ -14,14 +14,11 @@ let authenticatedUser
let variables
const getAllPasswordResets = async () => {
const session = driver.session()
try {
const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
const resets = transactionRes.records.map(record => record.get('r'))
return resets
} finally {
session.close()
}
const passwordResetQuery = await neode.cypher(
'MATCH (passwordReset:PasswordReset) RETURN passwordReset',
)
const resets = passwordResetQuery.records.map(record => record.get('passwordReset'))
return resets
}
beforeEach(() => {

View File

@ -57,17 +57,20 @@ export default {
PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => {
const { postId, data } = params
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-()
RETURN COUNT(DISTINCT emoted) as emotionsCount
`,
const readTxResultPromise = session.readTransaction(async transaction => {
const emotionsCountTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-()
RETURN COUNT(DISTINCT emoted) as emotionsCount
`,
{ postId, data },
)
const [emotionsCount] = transactionRes.records.map(record => {
return record.get('emotionsCount').low
})
return emotionsCountTransactionResponse.records.map(
record => record.get('emotionsCount').low,
)
})
try {
const [emotionsCount] = await readTxResultPromise
return emotionsCount
} finally {
session.close()
@ -76,16 +79,18 @@ export default {
PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => {
const { postId } = params
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId})
RETURN collect(emoted.emotion) as emotion`,
const readTxResultPromise = session.readTransaction(async transaction => {
const emotionsTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId})
RETURN collect(emoted.emotion) as emotion
`,
{ userId: context.user.id, postId },
)
const [emotions] = transactionRes.records.map(record => {
return record.get('emotion')
})
return emotionsTransactionResponse.records.map(record => record.get('emotion'))
})
try {
const [emotions] = await readTxResultPromise
return emotions
} finally {
session.close()
@ -98,25 +103,29 @@ export default {
delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
params.id = params.id || uuid()
const createPostCypher = `CREATE (post:Post {params})
SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime())
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
RETURN post`
const createPostVariables = { userId: context.user.id, categoryIds, params }
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const createPostTransactionResponse = await transaction.run(
`
CREATE (post:Post {params})
SET post.createdAt = toString(datetime())
SET post.updatedAt = toString(datetime())
WITH post
MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author)
WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
RETURN post
`,
{ userId: context.user.id, categoryIds, params },
)
return createPostTransactionResponse.records.map(record => record.get('post').properties)
})
try {
const transactionRes = await session.run(createPostCypher, createPostVariables)
const posts = transactionRes.records.map(record => record.get('post').properties)
return posts[0]
const [post] = await writeTxResultPromise
return post
} catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
throw new UserInputError('Post with this slug already exists!')
@ -129,38 +138,44 @@ export default {
const { categoryIds } = params
delete params.categoryIds
params = await fileUpload(params, { file: 'imageUpload', url: 'image' })
let updatePostCypher = `MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
const session = context.driver.session()
try {
if (categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
let updatePostCypher = `
MATCH (post:Post {id: $params.id})
SET post += $params
SET post.updatedAt = toString(datetime())
WITH post
`
if (categoryIds && categoryIds.length) {
const cypherDeletePreviousRelations = `
MATCH (post:Post { id: $params.id })-[previousRelations:CATEGORIZED]->(category:Category)
DELETE previousRelations
RETURN post, category
`
`
await session.run(cypherDeletePreviousRelations, { params })
await session.writeTransaction(transaction => {
return transaction.run(cypherDeletePreviousRelations, { params })
})
updatePostCypher += `
updatePostCypher += `
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
MERGE (post)-[:CATEGORIZED]->(category)
WITH post
`
}
}
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
const transactionRes = await session.run(updatePostCypher, updatePostVariables)
const [post] = transactionRes.records.map(record => {
return record.get('post').properties
updatePostCypher += `RETURN post`
const updatePostVariables = { categoryIds, params }
try {
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updatePostTransactionResponse = await transaction.run(
updatePostCypher,
updatePostVariables,
)
return updatePostTransactionResponse.records.map(record => record.get('post').properties)
})
const [post] = await writeTxResultPromise
return post
} finally {
session.close()
@ -169,23 +184,25 @@ export default {
DeletePost: async (object, args, context, resolveInfo) => {
const session = context.driver.session()
try {
// we cannot set slug to 'UNAVAILABE' because of unique constraints
const transactionRes = await session.run(
const writeTxResultPromise = session.writeTransaction(async transaction => {
const deletePostTransactionResponse = await transaction.run(
`
MATCH (post:Post {id: $postId})
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
SET post.deleted = TRUE
SET post.content = 'UNAVAILABLE'
SET post.contentExcerpt = 'UNAVAILABLE'
SET post.title = 'UNAVAILABLE'
SET comment.deleted = TRUE
REMOVE post.image
RETURN post
`,
MATCH (post:Post {id: $postId})
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
SET post.deleted = TRUE
SET post.content = 'UNAVAILABLE'
SET post.contentExcerpt = 'UNAVAILABLE'
SET post.title = 'UNAVAILABLE'
SET comment.deleted = TRUE
REMOVE post.image
RETURN post
`,
{ postId: args.id },
)
const [post] = transactionRes.records.map(record => record.get('post').properties)
return deletePostTransactionResponse.records.map(record => record.get('post').properties)
})
try {
const [post] = await writeTxResultPromise
return post
} finally {
session.close()
@ -195,21 +212,24 @@ export default {
const { to, data } = params
const { user } = context
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id})
MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo)
RETURN userFrom, postTo, emotedRelation`,
const writeTxResultPromise = session.writeTransaction(async transaction => {
const addPostEmotionsTransactionResponse = await transaction.run(
`
MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id})
MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo)
RETURN userFrom, postTo, emotedRelation`,
{ user, to, data },
)
const [emoted] = transactionRes.records.map(record => {
return addPostEmotionsTransactionResponse.records.map(record => {
return {
from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties },
...record.get('emotedRelation').properties,
}
})
})
try {
const [emoted] = await writeTxResultPromise
return emoted
} finally {
session.close()
@ -219,20 +239,25 @@ export default {
const { to, data } = params
const { id: from } = context.user
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id})
DELETE emotedRelation
RETURN userFrom, postTo`,
const writeTxResultPromise = session.writeTransaction(async transaction => {
const removePostEmotionsTransactionResponse = await transaction.run(
`
MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id})
DELETE emotedRelation
RETURN userFrom, postTo
`,
{ from, to, data },
)
const [emoted] = transactionRes.records.map(record => {
return removePostEmotionsTransactionResponse.records.map(record => {
return {
from: { ...record.get('userFrom').properties },
to: { ...record.get('postTo').properties },
emotion: data.emotion,
}
})
})
try {
const [emoted] = await writeTxResultPromise
return emoted
} finally {
session.close()
@ -344,21 +369,28 @@ export default {
relatedContributions: async (parent, params, context, resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent
const statement = `
MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
WHERE NOT post.deleted AND NOT post.disabled
RETURN DISTINCT post
LIMIT 10
`
let relatedContributions
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const relatedContributionsTransactionResponse = await transaction.run(
`
MATCH (p:Post {id: $id})-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
WHERE NOT post.deleted AND NOT post.disabled
RETURN DISTINCT post
LIMIT 10
`,
{ id },
)
return relatedContributionsTransactionResponse.records.map(
record => record.get('post').properties,
)
})
try {
const result = await session.run(statement, { id })
relatedContributions = result.records.map(r => r.get('post').properties)
const relatedContributions = await writeTxResultPromise
return relatedContributions
} finally {
session.close()
}
return relatedContributions
},
},
}

View File

@ -383,7 +383,10 @@ describe('UpdatePost', () => {
})
it('updates a post', async () => {
const expected = { data: { UpdatePost: { id: 'p9876', content: 'New content' } } }
const expected = {
data: { UpdatePost: { id: 'p9876', content: 'New content' } },
errors: undefined,
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
@ -394,6 +397,7 @@ describe('UpdatePost', () => {
data: {
UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) },
},
errors: undefined,
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
@ -421,6 +425,7 @@ describe('UpdatePost', () => {
categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]),
},
},
errors: undefined,
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
@ -441,6 +446,7 @@ describe('UpdatePost', () => {
categories: expect.arrayContaining([{ id: 'cat27' }]),
},
},
errors: undefined,
}
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
@ -722,6 +728,7 @@ describe('UpdatePost', () => {
},
],
},
errors: undefined,
}
variables = { orderBy: ['pinned_desc', 'createdAt_desc'] }
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject(

View File

@ -24,18 +24,19 @@ export default {
const { user } = await getUserAndBadge(params)
const session = context.driver.session()
try {
// silly neode cannot remove relationships
await session.run(
`
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
DELETE reward
RETURN rewardedUser
`,
{
badgeKey,
userId,
},
)
await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
DELETE reward
RETURN rewardedUser
`,
{
badgeKey,
userId,
},
)
})
} finally {
session.close()
}

View File

@ -1,3 +1,5 @@
import log from './helpers/databaseLogger'
export default {
Mutation: {
shout: async (_object, params, context, _resolveInfo) => {
@ -5,22 +7,24 @@ export default {
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node)
RETURN COUNT(relation) > 0 as isShouted`,
{
id,
type,
userId: context.user.id,
},
)
const [isShouted] = transactionRes.records.map(record => {
return record.get('isShouted')
const shoutWriteTxResultPromise = session.writeTransaction(async transaction => {
const shoutTransactionResponse = await transaction.run(
`
MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED{createdAt:toString(datetime())}]->(node)
RETURN COUNT(relation) > 0 as isShouted
`,
{
id,
type,
userId: context.user.id,
},
)
log(shoutTransactionResponse)
return shoutTransactionResponse.records.map(record => record.get('isShouted'))
})
const [isShouted] = await shoutWriteTxResultPromise
return isShouted
} finally {
session.close()
@ -31,20 +35,24 @@ export default {
const { id, type } = params
const session = context.driver.session()
try {
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node)
DELETE relation
RETURN COUNT(relation) > 0 as isShouted`,
{
id,
type,
userId: context.user.id,
},
)
const [isShouted] = transactionRes.records.map(record => {
return record.get('isShouted')
const unshoutWriteTxResultPromise = session.writeTransaction(async transaction => {
const unshoutTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node)
DELETE relation
RETURN COUNT(relation) > 0 as isShouted
`,
{
id,
type,
userId: context.user.id,
},
)
log(unshoutTransactionResponse)
return unshoutTransactionResponse.records.map(record => record.get('isShouted'))
})
const [isShouted] = await unshoutWriteTxResultPromise
return isShouted
} finally {
session.close()

View File

@ -1,8 +1,10 @@
import log from './helpers/databaseLogger'
export default {
Query: {
statistics: async (_parent, _args, { driver }) => {
const session = driver.session()
const response = {}
const counts = {}
try {
const mapping = {
countUsers: 'User',
@ -13,27 +15,28 @@ export default {
countFollows: 'FOLLOWS',
countShouts: 'SHOUTED',
}
const cypher = `
CALL apoc.meta.stats() YIELD labels, relTypesCount
RETURN labels, relTypesCount
`
const result = await session.run(cypher)
const [statistics] = await result.records.map(record => {
return {
...record.get('labels'),
...record.get('relTypesCount'),
}
const statisticsReadTxResultPromise = session.readTransaction(async transaction => {
const statisticsTransactionResponse = await transaction.run(
`
CALL apoc.meta.stats() YIELD labels, relTypesCount
RETURN labels, relTypesCount
`,
)
log(statisticsTransactionResponse)
return statisticsTransactionResponse.records.map(record => {
return {
...record.get('labels'),
...record.get('relTypesCount'),
}
})
})
const [statistics] = await statisticsReadTxResultPromise
Object.keys(mapping).forEach(key => {
const stat = statistics[mapping[key]]
response[key] = stat ? stat.toNumber() : 0
counts[key] = stat ? stat.toNumber() : 0
})
/*
* Note: invites count is calculated this way because invitation codes are not in use yet
*/
response.countInvites = response.countEmails - response.countUsers
return response
counts.countInvites = counts.countEmails - counts.countUsers
return counts
} finally {
session.close()
}

View File

@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server'
import { getNeode } from '../../bootstrap/neo4j'
import normalizeEmail from './helpers/normalizeEmail'
import log from './helpers/databaseLogger'
const neode = getNeode()
@ -25,17 +26,18 @@ export default {
email = normalizeEmail(email)
const session = driver.session()
try {
const result = await session.run(
`
MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})
RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1
`,
{ userEmail: email },
)
const [currentUser] = await result.records.map(record => {
return record.get('user')
const loginReadTxResultPromise = session.readTransaction(async transaction => {
const loginTransactionResponse = await transaction.run(
`
MATCH (user:User {deleted: false})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})
RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1
`,
{ userEmail: email },
)
log(loginTransactionResponse)
return loginTransactionResponse.records.map(record => record.get('user'))
})
const [currentUser] = await loginReadTxResultPromise
if (
currentUser &&
(await bcrypt.compareSync(password, currentUser.encryptedPassword)) &&

View File

@ -3,6 +3,7 @@ import fileUpload from './fileUpload'
import { getNeode } from '../../bootstrap/neo4j'
import { UserInputError, ForbiddenError } from 'apollo-server'
import Resolver from './helpers/Resolver'
import log from './helpers/databaseLogger'
const neode = getNeode()
@ -100,72 +101,89 @@ export default {
const blockedUser = await neode.find('User', args.id)
return blockedUser.toJson()
},
UpdateUser: async (object, args, context, resolveInfo) => {
const { termsAndConditionsAgreedVersion } = args
UpdateUser: async (_parent, params, context, _resolveInfo) => {
const { termsAndConditionsAgreedVersion } = params
if (termsAndConditionsAgreedVersion) {
const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g)
if (!regEx.test(termsAndConditionsAgreedVersion)) {
throw new ForbiddenError('Invalid version format!')
}
args.termsAndConditionsAgreedAt = new Date().toISOString()
params.termsAndConditionsAgreedAt = new Date().toISOString()
}
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
params = await fileUpload(params, { file: 'avatarUpload', url: 'avatar' })
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $params.id})
SET user += $params
SET user.updatedAt = toString(datetime())
RETURN user
`,
{ params },
)
return updateUserTransactionResponse.records.map(record => record.get('user').properties)
})
try {
const user = await neode.find('User', args.id)
if (!user) return null
await user.update({ ...args, updatedAt: new Date().toISOString() })
return user.toJson()
} catch (e) {
throw new UserInputError(e.message)
const [user] = await writeTxResultPromise
return user
} catch (error) {
throw new UserInputError(error.message)
} finally {
session.close()
}
},
DeleteUser: async (object, params, context, resolveInfo) => {
const { resource } = params
const session = context.driver.session()
let user
try {
if (resource && resource.length) {
await Promise.all(
resource.map(async node => {
await session.run(
await session.writeTransaction(transaction => {
resource.map(node => {
return transaction.run(
`
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
SET resource.deleted = true
SET resource.content = 'UNAVAILABLE'
SET resource.contentExcerpt = 'UNAVAILABLE'
SET comment.deleted = true
RETURN author`,
MATCH (resource:${node})<-[:WROTE]-(author:User {id: $userId})
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
SET resource.deleted = true
SET resource.content = 'UNAVAILABLE'
SET resource.contentExcerpt = 'UNAVAILABLE'
SET comment.deleted = true
RETURN author
`,
{
userId: context.user.id,
},
)
}),
)
})
})
}
// we cannot set slug to 'UNAVAILABE' because of unique constraints
const transactionResult = await session.run(
`
MATCH (user:User {id: $userId})
SET user.deleted = true
SET user.name = 'UNAVAILABLE'
SET user.about = 'UNAVAILABLE'
WITH user
OPTIONAL MATCH (user)<-[:BELONGS_TO]-(email:EmailAddress)
DETACH DELETE email
WITH user
OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia)
DETACH DELETE socialMedia
RETURN user`,
{ userId: context.user.id },
)
user = transactionResult.records.map(r => r.get('user').properties)[0]
const deleteUserTxResultPromise = session.writeTransaction(async transaction => {
const deleteUserTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId})
SET user.deleted = true
SET user.name = 'UNAVAILABLE'
SET user.about = 'UNAVAILABLE'
WITH user
OPTIONAL MATCH (user)<-[:BELONGS_TO]-(email:EmailAddress)
DETACH DELETE email
WITH user
OPTIONAL MATCH (user)<-[:OWNED_BY]-(socialMedia:SocialMedia)
DETACH DELETE socialMedia
RETURN user
`,
{ userId: context.user.id },
)
log(deleteUserTransactionResponse)
return deleteUserTransactionResponse.records.map(record => record.get('user').properties)
})
const [user] = await deleteUserTxResultPromise
return user
} finally {
session.close()
}
return user
},
},
User: {

View File

@ -68,6 +68,7 @@ describe('User', () => {
it('is permitted', async () => {
await expect(query({ query: userQuery, variables })).resolves.toMatchObject({
data: { User: [{ name: 'Johnny' }] },
errors: undefined,
})
})
@ -90,8 +91,7 @@ describe('User', () => {
})
describe('UpdateUser', () => {
let userParams
let variables
let userParams, variables
beforeEach(async () => {
userParams = {
@ -111,16 +111,23 @@ describe('UpdateUser', () => {
})
const updateUserMutation = gql`
mutation($id: ID!, $name: String, $termsAndConditionsAgreedVersion: String) {
mutation(
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String
) {
UpdateUser(
id: $id
name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) {
id
name
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
locationName
}
}
`
@ -152,7 +159,7 @@ describe('UpdateUser', () => {
authenticatedUser = await user.toJson()
})
it('name within specifications', async () => {
it('updates the name', async () => {
const expected = {
data: {
UpdateUser: {
@ -160,36 +167,13 @@ describe('UpdateUser', () => {
name: 'John Doughnut',
},
},
errors: undefined,
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('with `null` as name', async () => {
const variables = {
id: 'u47',
name: null,
}
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty(
'message',
'child "name" fails because ["name" contains an invalid value, "name" must be a string]',
)
})
it('with too short name', async () => {
const variables = {
id: 'u47',
name: ' ',
}
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty(
'message',
'child "name" fails because ["name" length must be at least 3 characters long]',
)
})
describe('given a new agreed version of terms and conditions', () => {
beforeEach(async () => {
variables = { ...variables, termsAndConditionsAgreedVersion: '0.0.2' }
@ -202,6 +186,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: expect.any(String),
}),
},
errors: undefined,
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -222,6 +207,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: null,
}),
},
errors: undefined,
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -238,6 +224,14 @@ describe('UpdateUser', () => {
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty('message', 'Invalid version format!')
})
it('supports updating location', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States of America' }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: { locationName: 'Hamburg, New Jersey, United States of America' } },
errors: undefined,
})
})
})
})
@ -372,6 +366,7 @@ describe('DeleteUser', () => {
],
},
},
errors: undefined,
}
await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject(
expectedResponse,
@ -418,6 +413,7 @@ describe('DeleteUser', () => {
],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: deleteUserMutation, variables }),
@ -465,6 +461,7 @@ describe('DeleteUser', () => {
],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: deleteUserMutation, variables }),
@ -511,6 +508,7 @@ describe('DeleteUser', () => {
],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: deleteUserMutation, variables }),

View File

@ -26,7 +26,7 @@ enum _UserOrdering {
type User {
id: ID!
actorId: String
name: String
name: String!
email: String! @cypher(statement: "MATCH (this)-[: PRIMARY_EMAIL]->(e: EmailAddress) RETURN e.email")
slug: String!
avatar: String

View File

@ -29,10 +29,16 @@ const factories = {
export const cleanDatabase = async (options = {}) => {
const { driver = getDriver() } = options
const cypher = 'MATCH (n) DETACH DELETE n'
const session = driver.session()
try {
return await session.run(cypher)
await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (everything)
DETACH DELETE everything
`,
)
})
} finally {
session.close()
}

View File

@ -46,7 +46,7 @@
<script>
import { mapGetters, mapMutations } from 'vuex'
import { allowEmbedIframesMutation } from '~/graphql/User.js'
import { updateUserMutation } from '~/graphql/User.js'
export default {
name: 'embed-component',
@ -129,7 +129,7 @@ export default {
async updateEmbedSettings(allowEmbedIframes) {
try {
await this.$apollo.mutate({
mutation: allowEmbedIframesMutation(),
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
allowEmbedIframes,

View File

@ -33,12 +33,12 @@
</template>
<script>
import gql from 'graphql-tag'
import Dropdown from '~/components/Dropdown'
import find from 'lodash/find'
import orderBy from 'lodash/orderBy'
import locales from '~/locales'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
export default {
components: {
@ -87,14 +87,7 @@ export default {
if (!this.currentUser || !this.currentUser.id) return null
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`,
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
locale: this.$i18n.locale(),

View File

@ -21,7 +21,7 @@
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
import gql from 'graphql-tag'
import { updateUserMutation } from '~/graphql/User.js'
export default {
components: {
@ -62,14 +62,7 @@ export default {
const avatarUpload = file[0]
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!, $avatarUpload: Upload) {
UpdateUser(id: $id, avatarUpload: $avatarUpload) {
id
avatar
}
}
`,
mutation: updateUserMutation(),
variables: {
avatarUpload,
id: this.user.id,

View File

@ -140,23 +140,42 @@ export const unfollowUserMutation = i18n => {
`
}
export const allowEmbedIframesMutation = () => {
export const updateUserMutation = () => {
return gql`
mutation($id: ID!, $allowEmbedIframes: Boolean) {
UpdateUser(id: $id, allowEmbedIframes: $allowEmbedIframes) {
mutation(
$id: ID!
$slug: String
$name: String
$locationName: String
$about: String
$allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$locale: String
$termsAndConditionsAgreedVersion: String
$avatarUpload: Upload
) {
UpdateUser(
id: $id
slug: $slug
name: $name
locationName: $locationName
about: $about
allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
locale: $locale
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatarUpload: $avatarUpload
) {
id
slug
name
locationName
about
allowEmbedIframes
}
}
`
}
export const showShoutsPubliclyMutation = () => {
return gql`
mutation($id: ID!, $showShoutsPublicly: Boolean) {
UpdateUser(id: $id, showShoutsPublicly: $showShoutsPublicly) {
id
showShoutsPublicly
locale
termsAndConditionsAgreedVersion
avatar
}
}
`
@ -169,14 +188,3 @@ export const checkSlugAvailableQuery = gql`
}
}
`
export const localeMutation = () => {
return gql`
mutation($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`
}

View File

@ -37,7 +37,7 @@
<script>
import axios from 'axios'
import { mapGetters, mapMutations } from 'vuex'
import { allowEmbedIframesMutation } from '~/graphql/User.js'
import { updateUserMutation } from '~/graphql/User.js'
export default {
head() {
@ -69,7 +69,7 @@ export default {
async submit() {
try {
await this.$apollo.mutate({
mutation: allowEmbedIframesMutation(),
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
allowEmbedIframes: !this.disabled,

View File

@ -41,40 +41,14 @@
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import { CancelToken } from 'axios'
import UniqueSlugForm from '~/components/utils/UniqueSlugForm'
import { updateUserMutation } from '~/graphql/User'
let timeout
const mapboxToken = process.env.MAPBOX_TOKEN
/*
const query = gql`
query getUser($id: ID) {
User(id: $id) {
id
name
locationName
about
}
}
`
*/
const mutation = gql`
mutation($id: ID!, $slug: String, $name: String, $locationName: String, $about: String) {
UpdateUser(id: $id, slug: $slug, name: $name, locationName: $locationName, about: $about) {
id
slug
name
locationName
about
}
}
`
export default {
data() {
return {
@ -120,7 +94,7 @@ export default {
locationName = locationName && (locationName.label || locationName)
try {
await this.$apollo.mutate({
mutation,
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
name,

View File

@ -10,7 +10,7 @@
<script>
import { mapGetters, mapMutations } from 'vuex'
import { showShoutsPubliclyMutation } from '~/graphql/User'
import { updateUserMutation } from '~/graphql/User'
export default {
data() {
@ -36,7 +36,7 @@ export default {
async submit() {
try {
await this.$apollo.mutate({
mutation: showShoutsPubliclyMutation(),
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
showShoutsPublicly: this.shoutsAllowed,

View File

@ -24,17 +24,10 @@
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex'
import { VERSION } from '~/constants/terms-and-conditions-version.js'
const mutation = gql`
mutation($id: ID!, $termsAndConditionsAgreedVersion: String) {
UpdateUser(id: $id, termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion) {
id
termsAndConditionsAgreedVersion
}
}
`
import { updateUserMutation } from '~/graphql/User.js'
export default {
layout: 'default',
head() {
@ -74,7 +67,7 @@ export default {
async submit() {
try {
await this.$apollo.mutate({
mutation,
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
termsAndConditionsAgreedVersion: VERSION,