diff --git a/backend/src/jwt/decode.js b/backend/src/jwt/decode.js index 5b7881d20..5433a8c76 100644 --- a/backend/src/jwt/decode.js +++ b/backend/src/jwt/decode.js @@ -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, - } } diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.js b/backend/src/middleware/hashtags/hashtagsMiddleware.js index 53a8fed20..7d8593fd5 100644 --- a/backend/src/middleware/hashtags/hashtagsMiddleware.js +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.js @@ -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() diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index d09a96475..9c68d8c00 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -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' diff --git a/backend/src/middleware/nodes/locations.js b/backend/src/middleware/nodes/locations.js index 3e0ca6855..47262d7ba 100644 --- a/backend/src/middleware/nodes/locations.js +++ b/backend/src/middleware/nodes/locations.js @@ -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 diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index ac199a67d..837193773 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -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, }, } diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js index 53fa80ce8..c5f5990d3 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -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, diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 8f139f4c7..3b42ae7fe 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -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() diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index cda3fd335..1cd3c0b9c 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -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() } diff --git a/backend/src/middleware/userMiddleware.js b/backend/src/middleware/user/userMiddleware.js similarity index 75% rename from backend/src/middleware/userMiddleware.js rename to backend/src/middleware/user/userMiddleware.js index fafbd44e5..2ca61e69f 100644 --- a/backend/src/middleware/userMiddleware.js +++ b/backend/src/middleware/user/userMiddleware.js @@ -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) => { diff --git a/backend/src/middleware/user/userMiddleware.spec.js b/backend/src/middleware/user/userMiddleware.spec.js new file mode 100644 index 000000000..4ca8fd89f --- /dev/null +++ b/backend/src/middleware/user/userMiddleware.spec.js @@ -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) + }) + }) +}) diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index f36458e61..948e1a73a 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -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, }, diff --git a/backend/src/middleware/validation/validationMiddleware.spec.js b/backend/src/middleware/validation/validationMiddleware.spec.js index c3d0512ad..d093f939a 100644 --- a/backend/src/middleware/validation/validationMiddleware.spec.js +++ b/backend/src/middleware/validation/validationMiddleware.spec.js @@ -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!' }], + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/comments.js b/backend/src/schema/resolvers/comments.js index 97b461511..864d9412c 100644 --- a/backend/src/schema/resolvers/comments.js +++ b/backend/src/schema/resolvers/comments.js @@ -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() diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index e2d20d1bd..f96a60514 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -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, diff --git a/backend/src/schema/resolvers/helpers/createPasswordReset.js b/backend/src/schema/resolvers/helpers/createPasswordReset.js index 41214b501..dec55c893 100644 --- a/backend/src/schema/resolvers/helpers/createPasswordReset.js +++ b/backend/src/schema/resolvers/helpers/createPasswordReset.js @@ -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() } diff --git a/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js b/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js deleted file mode 100644 index a566e225a..000000000 --- a/backend/src/schema/resolvers/helpers/createPasswordReset.spec.js +++ /dev/null @@ -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', - }), - ], - ]) - }) - }) -}) diff --git a/backend/src/schema/resolvers/helpers/existingEmailAddress.js b/backend/src/schema/resolvers/helpers/existingEmailAddress.js index ee1a6af82..960b2066f 100644 --- a/backend/src/schema/resolvers/helpers/existingEmailAddress.js +++ b/backend/src/schema/resolvers/helpers/existingEmailAddress.js @@ -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 } diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index eca12900d..31369a8c7 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -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() } diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js index dfbfe8183..74c71e011 100644 --- a/backend/src/schema/resolvers/passwordReset.js +++ b/backend/src/schema/resolvers/passwordReset.js @@ -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() } diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js index a1968d288..be3c8c085 100644 --- a/backend/src/schema/resolvers/passwordReset.spec.js +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -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(() => { diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index b37a4abd5..6ae3a81d9 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -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 }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 752602fd9..dcbd16d5d 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -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( diff --git a/backend/src/schema/resolvers/rewards.js b/backend/src/schema/resolvers/rewards.js index 4d5d62aea..44bdab770 100644 --- a/backend/src/schema/resolvers/rewards.js +++ b/backend/src/schema/resolvers/rewards.js @@ -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() } diff --git a/backend/src/schema/resolvers/shout.js b/backend/src/schema/resolvers/shout.js index ada1172a4..70ebdf7ae 100644 --- a/backend/src/schema/resolvers/shout.js +++ b/backend/src/schema/resolvers/shout.js @@ -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() diff --git a/backend/src/schema/resolvers/statistics.js b/backend/src/schema/resolvers/statistics.js index 07b9e4cea..7ca9239f3 100644 --- a/backend/src/schema/resolvers/statistics.js +++ b/backend/src/schema/resolvers/statistics.js @@ -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() } diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index d5c6cd5ad..4d035d9fa 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -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)) && diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index d8d5fbb73..be9a69e80 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -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: { diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index 26e977a31..5d1ebd8e2 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -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 }), diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 243f45322..df6d831fa 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -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 diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 10db5cc03..8b80a4b4f 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -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() } diff --git a/webapp/components/Embed/EmbedComponent.vue b/webapp/components/Embed/EmbedComponent.vue index 5dc8ad00c..f1790304e 100644 --- a/webapp/components/Embed/EmbedComponent.vue +++ b/webapp/components/Embed/EmbedComponent.vue @@ -46,7 +46,7 @@