diff --git a/.vscode/settings.json b/.vscode/settings.json index 2091e5e03..8565bda8e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,4 @@ } ], "editor.formatOnSave": false, - "eslint.autoFixOnSave": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc015aa4..5fe6b9619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.1.13](https://github.com/Human-Connection/Human-Connection/compare/v0.1.12...v0.1.13) + +> 13 December 2019 + +- Update de.json [`#2492`](https://github.com/Human-Connection/Human-Connection/pull/2492) +- Fix broken scroll behaviour on index and profile page [`#2487`](https://github.com/Human-Connection/Human-Connection/pull/2487) +- Lokalise: Translations update [`#2503`](https://github.com/Human-Connection/Human-Connection/pull/2503) +- build(deps): bump node from 13.1.0-alpine to 13.3.0-alpine in /webapp [`#2454`](https://github.com/Human-Connection/Human-Connection/pull/2454) +- Lokalise: Translations update [`#2485`](https://github.com/Human-Connection/Human-Connection/pull/2485) +- build(deps-dev): bump css-loader from 3.3.0 to 3.3.2 in /webapp [`#2505`](https://github.com/Human-Connection/Human-Connection/pull/2505) +- build(deps-dev): bump cypress from 3.7.0 to 3.8.0 [`#2504`](https://github.com/Human-Connection/Human-Connection/pull/2504) +- Favor transaction functions [`#2433`](https://github.com/Human-Connection/Human-Connection/pull/2433) +- build(deps): bump nodemailer from 6.4.1 to 6.4.2 in /backend [`#2500`](https://github.com/Human-Connection/Human-Connection/pull/2500) +- Update en.json [`#2491`](https://github.com/Human-Connection/Human-Connection/pull/2491) +- Update es.json [`#2493`](https://github.com/Human-Connection/Human-Connection/pull/2493) +- Update fr.json [`#2494`](https://github.com/Human-Connection/Human-Connection/pull/2494) +- Update it.json [`#2496`](https://github.com/Human-Connection/Human-Connection/pull/2496) +- build(deps-dev): bump nodemon from 2.0.1 to 2.0.2 in /backend [`#2499`](https://github.com/Human-Connection/Human-Connection/pull/2499) +- build(deps): bump @nuxtjs/apollo from 4.0.0-rc18 to 4.0.0-rc19 in /webapp [`#2498`](https://github.com/Human-Connection/Human-Connection/pull/2498) +- build(deps): bump neo4j-graphql-js from 2.10.0 to 2.10.1 in /backend [`#2497`](https://github.com/Human-Connection/Human-Connection/pull/2497) +- Fix docker manifest on Travis CI [`#2488`](https://github.com/Human-Connection/Human-Connection/pull/2488) +- build(deps-dev): bump @babel/core from 7.7.4 to 7.7.5 [`#2453`](https://github.com/Human-Connection/Human-Connection/pull/2453) +- build(deps-dev): bump cypress-file-upload from 3.5.0 to 3.5.1 [`#2489`](https://github.com/Human-Connection/Human-Connection/pull/2489) +- build(deps): bump cookie-universal-nuxt from 2.0.19 to 2.1.0 in /webapp [`#2490`](https://github.com/Human-Connection/Human-Connection/pull/2490) +- Update to version 0.1.12 [`#2483`](https://github.com/Human-Connection/Human-Connection/pull/2483) +- Lokalise: update of locale/ru.json [`60b3035`](https://github.com/Human-Connection/Human-Connection/commit/60b3035a3d475cb481130c6fe94f2901711a4053) +- Write test/refactor tests/resolvers/middleware [`d375ebe`](https://github.com/Human-Connection/Human-Connection/commit/d375ebe7d90e3251b17f59ffba8fb1470923ebe8) +- Fix this annoying bug with a tested helper [`e24d803`](https://github.com/Human-Connection/Human-Connection/commit/e24d8035b13040dc29f5f9cb033de8c1a401ac34) + #### [v0.1.12](https://github.com/Human-Connection/Human-Connection/compare/v0.1.10...v0.1.12) > 10 December 2019 diff --git a/VERSION b/VERSION index 0e24a92ff..7ac4e5e38 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.12 +0.1.13 diff --git a/backend/package.json b/backend/package.json index 21b5587d5..0cc8d3666 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,12 +34,12 @@ "dependencies": { "@hapi/joi": "^16.1.8", "@sentry/node": "^5.10.2", - "apollo-cache-inmemory": "~1.6.3", - "apollo-client": "~2.6.4", + "apollo-cache-inmemory": "~1.6.5", + "apollo-client": "~2.6.8", "apollo-link-context": "~1.0.19", "apollo-link-http": "~1.5.16", "apollo-server": "~2.9.13", - "apollo-server-express": "^2.9.7", + "apollo-server-express": "^2.9.14", "babel-plugin-transform-runtime": "^6.23.0", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.3", @@ -62,27 +62,27 @@ "linkifyjs": "~2.1.8", "lodash": "~4.17.14", "merge-graphql-schemas": "^1.7.3", - "metascraper": "^5.8.9", - "metascraper-audio": "^5.8.7", - "metascraper-author": "^5.8.7", + "metascraper": "^5.8.12", + "metascraper-audio": "^5.8.10", + "metascraper-author": "^5.8.12", "metascraper-clearbit-logo": "^5.3.0", - "metascraper-date": "^5.8.7", - "metascraper-description": "^5.8.7", - "metascraper-image": "^5.8.7", - "metascraper-lang": "^5.8.9", + "metascraper-date": "^5.8.12", + "metascraper-description": "^5.8.12", + "metascraper-image": "^5.8.12", + "metascraper-lang": "^5.8.10", "metascraper-lang-detector": "^4.10.2", - "metascraper-logo": "^5.8.7", + "metascraper-logo": "^5.8.12", "metascraper-publisher": "^5.8.7", - "metascraper-soundcloud": "^5.8.9", - "metascraper-title": "^5.8.7", + "metascraper-soundcloud": "^5.8.12", + "metascraper-title": "^5.8.12", "metascraper-url": "^5.8.7", - "metascraper-video": "^5.8.9", - "metascraper-youtube": "^5.8.9", + "metascraper-video": "^5.8.12", + "metascraper-youtube": "^5.8.12", "minimatch": "^3.0.4", - "mustache": "^3.1.0", + "mustache": "^3.2.0", "neo4j-driver": "~1.7.6", - "neo4j-graphql-js": "^2.10.1", - "neode": "^0.3.3", + "neo4j-graphql-js": "^2.10.2", + "neode": "^0.3.6", "node-fetch": "~2.6.0", "nodemailer": "^6.4.2", "nodemailer-html-to-text": "^3.1.0", @@ -97,13 +97,13 @@ "xregexp": "^4.2.4" }, "devDependencies": { - "@babel/cli": "~7.7.5", + "@babel/cli": "~7.7.7", "@babel/core": "~7.7.5", - "@babel/node": "~7.7.4", + "@babel/node": "~7.7.7", "@babel/plugin-proposal-throw-expressions": "^7.7.4", "@babel/preset-env": "~7.7.6", "@babel/register": "~7.7.0", - "apollo-server-testing": "~2.9.13", + "apollo-server-testing": "~2.9.14", "babel-core": "~7.0.0-0", "babel-eslint": "~10.0.3", "babel-jest": "~24.9.0", @@ -115,7 +115,7 @@ "eslint-plugin-import": "~2.19.1", "eslint-plugin-jest": "~23.1.1", "eslint-plugin-node": "~10.0.0", - "eslint-plugin-prettier": "~3.1.1", + "eslint-plugin-prettier": "~3.1.2", "eslint-plugin-promise": "~4.2.1", "eslint-plugin-standard": "~4.0.1", "jest": "~24.9.0", 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 18f7c7c2f..a4c41871f 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/models/Post.js b/backend/src/models/Post.js index 18dc0e464..bd6eda2e4 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -39,5 +39,6 @@ module.exports = { default: () => new Date().toISOString(), }, language: { type: 'string', allow: [null] }, + imageBlurred: { type: 'boolean', default: false }, imageAspectRatio: { type: 'float', default: 1.0 }, } diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index 332e6a3ea..433cc5a6f 100644 --- a/backend/src/models/User.spec.js +++ b/backend/src/models/User.spec.js @@ -70,15 +70,11 @@ describe('slug', () => { }) it(' ', async () => { - await expect(createUser({ slug: 'matt rider' })).rejects.toThrow( - /fails to match the required pattern/, - ) + await expect(createUser({ slug: 'matt rider' })).rejects.toThrow('ERROR_VALIDATION') }) it('ä', async () => { - await expect(createUser({ slug: 'mätt' })).rejects.toThrow( - /fails to match the required pattern/, - ) + await expect(createUser({ slug: 'mätt' })).rejects.toThrow('ERROR_VALIDATION') }) }) }) 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 5d8d1bffb..4a857a63c 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -34,17 +34,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() @@ -53,16 +56,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() @@ -75,25 +80,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!') @@ -106,38 +115,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() @@ -146,23 +161,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() @@ -172,21 +189,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() @@ -196,20 +216,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() @@ -293,6 +318,7 @@ export default { 'language', 'pinnedAt', 'pinned', + 'imageBlurred', 'imageAspectRatio', ], hasMany: { @@ -321,21 +347,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/registration.js b/backend/src/schema/resolvers/registration.js index 9d5d5f09a..1a6bda1c8 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -40,7 +40,7 @@ export default { `, { nonce, email }, ) - const emailAddress = await neode.hydrateFirst(result, 'email', neode.model('Email')) + const emailAddress = await neode.hydrateFirst(result, 'email', neode.model('EmailAddress')) if (!emailAddress) throw new UserInputError('Invalid email or nonce') args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) args = await encryptPassword(args) 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/Post.gql b/backend/src/schema/types/type/Post.gql index 514bccb9b..71fcb9605 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -82,6 +82,7 @@ input _PostFilter { emotions_none: _PostEMOTEDFilter emotions_single: _PostEMOTEDFilter emotions_every: _PostEMOTEDFilter + imageBlurred: Boolean } enum _PostOrdering { @@ -127,6 +128,7 @@ type Post { createdAt: String updatedAt: String language: String + imageBlurred: Boolean pinnedAt: String @cypher( statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt" ) @@ -140,7 +142,6 @@ type Post { LIMIT 10 """ ) - tags: [Tag]! @relation(name: "TAGGED", direction: "OUT") categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") @@ -183,6 +184,7 @@ type Mutation { language: String categoryIds: [ID] contentExcerpt: String + imageBlurred: Boolean imageAspectRatio: Float ): Post UpdatePost( @@ -196,6 +198,7 @@ type Mutation { visibility: Visibility language: String categoryIds: [ID] + imageBlurred: Boolean imageAspectRatio: Float ): Post DeletePost(id: ID!): Post @@ -217,6 +220,7 @@ type Query { createdAt: String updatedAt: String language: String + imageBlurred: Boolean first: Int offset: Int orderBy: [_PostOrdering] 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/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 2443619ae..d1a46d71b 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -19,6 +19,7 @@ export default function create() { visibility: 'public', deleted: false, categoryIds: [], + imageBlurred: false, imageAspectRatio: 1.333, } args = { diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 475a7b54f..4178169bb 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -352,6 +352,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] language: sample(languages), image: faker.image.unsplash.food(300, 169), categoryIds: ['cat16'], + imageBlurred: true, imageAspectRatio: 300 / 169, }), factory.create('Post', { @@ -398,6 +399,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] author: dewey, id: 'p10', categoryIds: ['cat10'], + imageBlurred: true, }), factory.create('Post', { author: louie, @@ -444,6 +446,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] $title: String! $content: String! $categoryIds: [ID] + $imageBlurred: Boolean $imageAspectRatio: Float ) { CreatePost( @@ -451,6 +454,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] title: $title content: $content categoryIds: $categoryIds + imageBlurred: $imageBlurred imageAspectRatio: $imageAspectRatio ) { id diff --git a/backend/src/server.js b/backend/src/server.js index 91b9a13aa..122f23683 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -38,6 +38,12 @@ const createServer = options => { schema: middleware(schema), debug: !!CONFIG.DEBUG, tracing: !!CONFIG.DEBUG, + formatError: error => { + if (error.message === 'ERROR_VALIDATION') { + return new Error(error.originalError.details.map(d => d.message)) + } + return error + }, } const server = new ApolloServer(Object.assign({}, defaults, options)) diff --git a/backend/yarn.lock b/backend/yarn.lock index 56796f040..076df4e61 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -33,10 +33,10 @@ resolved "https://registry.yarnpkg.com/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.24.tgz#3ce939cb127fb8aaa3ffc1e90dff9b8af9f2e3dc" integrity sha512-8GqG48m1XqyXh4mIZrtB5xOhUwSsh1WsrrsaZQOEYYql3YN9DEu9OOSg0ILzXHZo/h2Q74777YE4YzlArQzQEQ== -"@babel/cli@~7.7.5": - version "7.7.5" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.7.5.tgz#25702cc65418efc06989af3727897b9f4c8690b6" - integrity sha512-y2YrMGXM3NUyu1Myg0pxg+Lx6g8XhEyvLHYNRwTBV6fDek3H7Io6b7N/LXscLs4HWn4HxMdy7f2rM1rTMp2mFg== +"@babel/cli@~7.7.7": + version "7.7.7" + resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.7.7.tgz#56849acbf81d1a970dd3d1b3097c8ebf5da3f534" + integrity sha512-XQw5KyCZyu/M8/0rYiZyuwbgIQNzOrJzs9dDLX+MieSgBwTLvTj4QVbLmxJACAIvQIDT7PtyHN2sC48EOWTgaA== dependencies: commander "^4.0.1" convert-source-map "^1.1.0" @@ -279,17 +279,18 @@ esutils "^2.0.2" js-tokens "^4.0.0" -"@babel/node@~7.7.4": - version "7.7.4" - resolved "https://registry.yarnpkg.com/@babel/node/-/node-7.7.4.tgz#de1cc9c67b335a19e4f71208554779bc63719f5a" - integrity sha512-Vhhq2kK+BpsR2tW35zP8yOJZ7ONMVBwCD9fmNeRTU3MNNpcJDrrtVP5NK8ZX4nQAs0GSq6ky8noyn6MCVgL08g== +"@babel/node@~7.7.7": + version "7.7.7" + resolved "https://registry.yarnpkg.com/@babel/node/-/node-7.7.7.tgz#10c488ca36da07670be0131679c4e22f9d7795d4" + integrity sha512-QWWbQ6AyDffz6mA2mF0jixb/3IyRlqWgz5JNa2F6kSYe4vhPEytwuGmanx0NQJxBufDjffm/jYPuIfKfAyVzuA== dependencies: - "@babel/register" "^7.7.4" + "@babel/register" "^7.7.7" commander "^2.8.1" core-js "^3.2.1" lodash "^4.17.13" node-environment-flags "^1.0.5" regenerator-runtime "^0.13.3" + resolve "^1.13.1" v8flags "^3.1.1" "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.7.4", "@babel/parser@^7.7.5": @@ -704,10 +705,10 @@ js-levenshtein "^1.1.3" semver "^5.5.0" -"@babel/register@^7.7.4", "@babel/register@~7.7.0": - version "7.7.4" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.7.4.tgz#45a4956471a9df3b012b747f5781cc084ee8f128" - integrity sha512-/fmONZqL6ZMl9KJUYajetCrID6m0xmL4odX7v+Xvoxcv0DdbP/oO0TWIeLUCHqczQ6L6njDMqmqHFy2cp3FFsA== +"@babel/register@^7.7.7", "@babel/register@~7.7.0": + version "7.7.7" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.7.7.tgz#46910c4d1926b9c6096421b23d1f9e159c1dcee1" + integrity sha512-S2mv9a5dc2pcpg/ConlKZx/6wXaEwHeqfo7x/QbXsdCAZm+WJC1ekVvL1TVxNsedTs5y/gG63MhJTEsmwmjtiA== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.13" @@ -1034,10 +1035,10 @@ url-regex "~4.1.1" video-extensions "~1.1.0" -"@metascraper/helpers@^5.8.7": - version "5.8.7" - resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.8.7.tgz#b05f83f2a90001f7753c18a8b1bb978bd7c2f9d9" - integrity sha512-gDErMAA3d1CdkGxvAG4cDi7D2+fReZpD6lzYNJ/gsq45U3Pdz7ltsAvbp4amK92bGKYYPZtnUq85Wrr+Q+e06Q== +"@metascraper/helpers@^5.8.10", "@metascraper/helpers@^5.8.12", "@metascraper/helpers@^5.8.7": + version "5.8.12" + resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.8.12.tgz#c4c1375a90ee9b674f8fb4d5a65cce6f5c6ce30d" + integrity sha512-hmaIRXWcLGFWAXFKBHECHhf3VhHrbz/iV6spPtTeYyxCVO1TX62qYigqbizZwHk4dGeU1cTtbT2YN8/RCr1RiQ== dependencies: audio-extensions "0.0.0" chrono-node "~1.3.11" @@ -1606,45 +1607,45 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-cache-control@^0.8.8: - version "0.8.8" - resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.8.8.tgz#c6de9ef3a154560f6cf26ce7159e62438c1ac022" - integrity sha512-hpIJg3Tmb6quA111lrVO+d3qcyYRlJ8JqbeQdcgwLT3fb2VQzk21SrBZYl2oMM4ZqSOWCZWg4/Cn9ARYqdWjKA== +apollo-cache-control@^0.8.9: + version "0.8.9" + resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.8.9.tgz#1c53dcb6cc209646b73b4ba8803ff6ea50bea2a7" + integrity sha512-EFRAEL13QkMbXhl0NSABVS0wfiKYIVV4sDR+XNtRy3EWf2rWw7xulYFDMPiujjtElF2qjTswzcpLtYOXgOZN9A== dependencies: apollo-server-env "^2.4.3" - graphql-extensions "^0.10.7" + graphql-extensions "^0.10.8" -apollo-cache-inmemory@~1.6.3: - version "1.6.3" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.3.tgz#826861d20baca4abc45f7ca7a874105905b8525d" - integrity sha512-S4B/zQNSuYc0M/1Wq8dJDTIO9yRgU0ZwDGnmlqxGGmFombOZb9mLjylewSfQKmjNpciZ7iUIBbJ0mHlPJTzdXg== +apollo-cache-inmemory@~1.6.5: + version "1.6.5" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.5.tgz#2ccaa3827686f6ed7fb634203dbf2b8d7015856a" + integrity sha512-koB76JUDJaycfejHmrXBbWIN9pRKM0Z9CJGQcBzIOtmte1JhEBSuzsOUu7NQgiXKYI4iGoMREcnaWffsosZynA== dependencies: - apollo-cache "^1.3.2" - apollo-utilities "^1.3.2" + apollo-cache "^1.3.4" + apollo-utilities "^1.3.3" optimism "^0.10.0" ts-invariant "^0.4.0" - tslib "^1.9.3" + tslib "^1.10.0" -apollo-cache@1.3.2, apollo-cache@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a" - integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg== +apollo-cache@1.3.4, apollo-cache@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.4.tgz#0c9f63c793e1cd6e34c450f7668e77aff58c9a42" + integrity sha512-7X5aGbqaOWYG+SSkCzJNHTz2ZKDcyRwtmvW4mGVLRqdQs+HxfXS4dUS2CcwrAj449se6tZ6NLUMnjko4KMt3KA== dependencies: - apollo-utilities "^1.3.2" - tslib "^1.9.3" + apollo-utilities "^1.3.3" + tslib "^1.10.0" -apollo-client@~2.6.4: - version "2.6.4" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.4.tgz#872c32927263a0d34655c5ef8a8949fbb20b6140" - integrity sha512-oWOwEOxQ9neHHVZrQhHDbI6bIibp9SHgxaLRVPoGvOFy7OH5XUykZE7hBQAVxq99tQjBzgytaZffQkeWo1B4VQ== +apollo-client@~2.6.8: + version "2.6.8" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.8.tgz#01cebc18692abf90c6b3806414e081696b0fa537" + integrity sha512-0zvJtAcONiozpa5z5zgou83iEKkBaXhhSSXJebFHRXs100SecDojyUWKjwTtBPn9HbM6o5xrvC5mo9VQ5fgAjw== dependencies: "@types/zen-observable" "^0.8.0" - apollo-cache "1.3.2" + apollo-cache "1.3.4" apollo-link "^1.0.0" - apollo-utilities "1.3.2" + apollo-utilities "1.3.3" symbol-observable "^1.0.2" ts-invariant "^0.4.0" - tslib "^1.9.3" + tslib "^1.10.0" zen-observable "^0.8.0" apollo-datasource@^0.6.3: @@ -1662,18 +1663,18 @@ apollo-engine-reporting-protobuf@^0.4.4: dependencies: "@apollo/protobufjs" "^1.0.3" -apollo-engine-reporting@^1.4.11: - version "1.4.11" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.4.11.tgz#ea4501925c201e62729a11ce36284a89f1eaa4f5" - integrity sha512-7ZkbOGvPfWppN8+1KHzyHPrJTMOmrMUy38unao2c9TTToOAnEvx2MtUTo6mr3aw/g8UQYUf0x2Cq+K2YSlUTPw== +apollo-engine-reporting@^1.4.12: + version "1.4.12" + resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.4.12.tgz#b33a6eae0ffa7b827dd813bed335260e6ad012d2" + integrity sha512-W1PpXaXSrqZu4Ae9NrjWXtTL9HFbQPynQLiGDAsDmCsL/wRAVyl6qRhVteSj7drwgXgAH5TwtUCnjJgSI+ixlg== dependencies: apollo-engine-reporting-protobuf "^0.4.4" apollo-graphql "^0.3.4" apollo-server-caching "^0.5.0" apollo-server-env "^2.4.3" - apollo-server-types "^0.2.8" + apollo-server-types "^0.2.9" async-retry "^1.2.1" - graphql-extensions "^0.10.7" + graphql-extensions "^0.10.8" apollo-env@0.5.1, apollo-env@^0.5.1: version "0.5.1" @@ -1743,26 +1744,26 @@ apollo-server-caching@^0.5.0: dependencies: lru-cache "^5.0.0" -apollo-server-core@^2.9.13: - version "2.9.13" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.13.tgz#29fee69be56d30605b0a06cd755fd39e0409915f" - integrity sha512-iXTGNCtouB0Xe37ySovuZO69NBYOByJlZfUc87gj0pdcz0WbdfUp7qUtNzy3onp63Zo60TFkHWhGNcBJYFluzw== +apollo-server-core@^2.9.13, apollo-server-core@^2.9.14: + version "2.9.14" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.9.14.tgz#9f68ec605df15cbe509a1b9f384923aef63d4169" + integrity sha512-Vc8TicXFFZGuEgo5AY1Ey0XuvHn7NQS1y7WxOQnr85KJ2zeRa6uIT8tU+73ZObzan3nlm9ysYtfSXh2QL21oyg== dependencies: "@apollographql/apollo-tools" "^0.4.0" "@apollographql/graphql-playground-html" "1.6.24" "@types/graphql-upload" "^8.0.0" "@types/ws" "^6.0.0" - apollo-cache-control "^0.8.8" + apollo-cache-control "^0.8.9" apollo-datasource "^0.6.3" - apollo-engine-reporting "^1.4.11" + apollo-engine-reporting "^1.4.12" apollo-server-caching "^0.5.0" apollo-server-env "^2.4.3" apollo-server-errors "^2.3.4" - apollo-server-plugin-base "^0.6.8" - apollo-server-types "^0.2.8" - apollo-tracing "^0.8.8" + apollo-server-plugin-base "^0.6.9" + apollo-server-types "^0.2.9" + apollo-tracing "^0.8.9" fast-json-stable-stringify "^2.0.0" - graphql-extensions "^0.10.7" + graphql-extensions "^0.10.8" graphql-tag "^2.9.2" graphql-tools "^4.0.0" graphql-upload "^8.0.2" @@ -1783,10 +1784,10 @@ apollo-server-errors@^2.3.4: resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.4.tgz#b70ef01322f616cbcd876f3e0168a1a86b82db34" integrity sha512-Y0PKQvkrb2Kd18d1NPlHdSqmlr8TgqJ7JQcNIfhNDgdb45CnqZlxL1abuIRhr8tiw8OhVOcFxz2KyglBi8TKdA== -apollo-server-express@^2.9.13, apollo-server-express@^2.9.7: - version "2.9.13" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.13.tgz#abb00bcf85d86a6e0e9105ce3b7fae9a7748156b" - integrity sha512-M306e07dpZ8YpZx4VBYa0FWlt+wopj4Bwn0Iy1iJ6VjaRyGx2HCUJvLpHZ+D0TIXtQ2nX3DTYeOouVaDDwJeqQ== +apollo-server-express@^2.9.13, apollo-server-express@^2.9.14: + version "2.9.14" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.9.14.tgz#32b9c46248f7f4e71d51bfbdbec34e1880f1c93b" + integrity sha512-ai+VKPlOUzJsbSQcazjATNtWwdgcvZBWBCbTF7ZUC9Uo6FfSlKOmP3raQAq+gKqsnFwv34p4k17c/Asw5ZjSMQ== dependencies: "@apollographql/graphql-playground-html" "1.6.24" "@types/accepts" "^1.3.5" @@ -1794,8 +1795,8 @@ apollo-server-express@^2.9.13, apollo-server-express@^2.9.7: "@types/cors" "^2.8.4" "@types/express" "4.17.1" accepts "^1.3.5" - apollo-server-core "^2.9.13" - apollo-server-types "^0.2.8" + apollo-server-core "^2.9.14" + apollo-server-types "^0.2.9" body-parser "^1.18.3" cors "^2.8.4" express "^4.17.1" @@ -1805,24 +1806,24 @@ apollo-server-express@^2.9.13, apollo-server-express@^2.9.7: subscriptions-transport-ws "^0.9.16" type-is "^1.6.16" -apollo-server-plugin-base@^0.6.8: - version "0.6.8" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.8.tgz#94cb9a6d806b7057d1d42202292d2adcf2cf0e7a" - integrity sha512-0pKCjcg9gHBK8qlb280+N0jl99meixQtxXnMJFyIfD+45OpKQ+WolHIbO0oZgNEt7r/lNWwH8v3l5yYm1ghz1A== +apollo-server-plugin-base@^0.6.9: + version "0.6.9" + resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.6.9.tgz#0b47028f75066f2429935b0234fe58217bcc6de6" + integrity sha512-75rorl0y07PK7A/U1Oe9unLIGgbmy1T6uaCQ5zl8zy+mtmFFcH1nYERfXZha1eTDWLCx0F/xNI6YJmWxSisc5w== dependencies: - apollo-server-types "^0.2.8" + apollo-server-types "^0.2.9" -apollo-server-testing@~2.9.13: - version "2.9.13" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.13.tgz#7a4efc0eb01d7297716f089121c7440a620bb640" - integrity sha512-c1xl4g5KhMfPpL5xdzxPJLY53+yK/kMAWxIASthRrOSZNgStTe7pCAJ06Nk3NB8M5GwfJK3cJiVkLfZRSt9+jQ== +apollo-server-testing@~2.9.14: + version "2.9.14" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.9.14.tgz#3b442a22b109c7ef7758bc1749dc0ab9923eb605" + integrity sha512-An9T0kUpqPOJnuoqGRIbx/c5iy/WRyZnVrfbCjQ0ux9n1reAoMzhmIdDDCIkl8+tu4UfTcjuNl4af5WRY6Lakw== dependencies: - apollo-server-core "^2.9.13" + apollo-server-core "^2.9.14" -apollo-server-types@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.8.tgz#729208a8dd72831af3aa4f1eb584022ada146e6b" - integrity sha512-5OclxkAqjhuO75tTNHpSO/+doJZ+VlRtTefnrPJdK/uwVew9U/VUCWkYdryZWwEyVe1nvQ/4E7RYR4tGb8l8wA== +apollo-server-types@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.2.9.tgz#d943817772e8712c7479be2403878be849183995" + integrity sha512-Iu9twx3lycH41F8shmrb33b4y0mNBz1chBdTIaSgIMmNwPDR4xs4eB6iyTK5swnaYC1eW+c+t5lHRUk7yexs/g== dependencies: apollo-engine-reporting-protobuf "^0.4.4" apollo-server-caching "^0.5.0" @@ -1839,23 +1840,23 @@ apollo-server@~2.9.13: graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@^0.8.8: - version "0.8.8" - resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.8.8.tgz#bfaffd76dc12ed5cc1c1198b5411864affdb1b83" - integrity sha512-aIwT2PsH7VZZPaNrIoSjzLKMlG644d2Uf+GYcoMd3X6UEyg1sXdWqkKfCeoS6ChJKH2khO7MXAvOZC03UnCumQ== +apollo-tracing@^0.8.9: + version "0.8.9" + resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.8.9.tgz#2fde222dd60d21a211ebdbe4bc8d8674fdfb5e14" + integrity sha512-DYHPUW0rFcxxtI8+qU3leNU+fKfq9NPTjgPMr/AJmxKfsdOI6QgfVzVP/khiik0kU0+BMl5zBplwEDDdgbkUlg== dependencies: apollo-server-env "^2.4.3" - graphql-extensions "^0.10.7" + graphql-extensions "^0.10.8" -apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" - integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== +apollo-utilities@1.3.3, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.3.tgz#f1854715a7be80cd810bc3ac95df085815c0787c" + integrity sha512-F14aX2R/fKNYMvhuP2t9GD9fggID7zp5I96MF5QeKYWDWTrkRdHRp4+SVfXUVN+cXOaB/IebfvRtzPf25CM0zw== dependencies: "@wry/equality" "^0.1.2" fast-json-stable-stringify "^2.0.0" ts-invariant "^0.4.0" - tslib "^1.9.3" + tslib "^1.10.0" aproba@^1.0.3: version "1.2.0" @@ -2485,16 +2486,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -clean-stack@^2.0.0: +clean-stack@^2.0.0, clean-stack@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== -clean-stack@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.0.0.tgz#301bfa9e8dd2d3d984c0e542f7aa67b996f63e0a" - integrity sha512-VEoL9Qh7I8s8iHnV53DaeWSt8NJ0g3khMfK6NiCPB7H657juhro+cSw2O88uo3bo0c0X5usamtXk0/Of0wXa5A== - cli-boxes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" @@ -3412,10 +3408,10 @@ eslint-plugin-node@~10.0.0: resolve "^1.10.1" semver "^6.1.0" -eslint-plugin-prettier@~3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.1.tgz#507b8562410d02a03f0ddc949c616f877852f2ba" - integrity sha512-A+TZuHZ0KU0cnn56/9mfR7/KjUJ9QNVXUhwvRFSR7PGPe0zQR6PTkmyqg1AtUUEOzTqeRsUwyKFh0oVZKVCrtA== +eslint-plugin-prettier@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz#432e5a667666ab84ce72f945c72f77d996a5c9ba" + integrity sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA== dependencies: prettier-linter-helpers "^1.0.0" @@ -4133,14 +4129,14 @@ graphql-custom-directives@~0.2.14: moment "^2.22.2" numeral "^2.0.6" -graphql-extensions@^0.10.7: - version "0.10.7" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.7.tgz#ca9f8ec3cb0af1739b48ca42280ec9162ad116d1" - integrity sha512-YuP7VQxNePG4bWRQ5Vk+KRMbZ9r1IWCqCCogOMz/1ueeQ4gZe93eGRcb0vhpOdMFnCX6Vyvd4+sC+N6LR3YFOQ== +graphql-extensions@^0.10.8: + version "0.10.8" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.10.8.tgz#d338048dfd9f09ec7953c6da2c8c06b3520cbb20" + integrity sha512-cUcc014vz+pfwcER8pc4ts/WWhDCrC9jhNFIiWYYntd2TshS+tZFsZ362i4P2VYLbpYCgFiO+xRY1f2mylyz5A== dependencies: "@apollographql/apollo-tools" "^0.4.0" apollo-server-env "^2.4.3" - apollo-server-types "^0.2.8" + apollo-server-types "^0.2.9" graphql-iso-date@~3.6.1: version "3.6.1" @@ -5810,19 +5806,19 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -metascraper-audio@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-audio/-/metascraper-audio-5.8.7.tgz#ce27b1f4056c1d1cbaa2cec0e819c3704f38fff4" - integrity sha512-ew9KZKOIl3u0500j7qIR/ZNiVtSohuyyiIWSxJVEeeguEOwAhMpOrpYAEkvKRo5CB89F2PNBIsXJIzMC4BWFrw== +metascraper-audio@^5.8.10: + version "5.8.10" + resolved "https://registry.yarnpkg.com/metascraper-audio/-/metascraper-audio-5.8.10.tgz#bc7bc0471ee178ab747baec4fb9bf7443078980d" + integrity sha512-uR4PCG7mxz7GLZ3I3x83sTCAaD/+MMTSf5rtP+shfdGJCm6h3mNmUpZm6hlBunmBx/PpDpwdI34rkl2A8SUjnQ== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.10" -metascraper-author@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-author/-/metascraper-author-5.8.7.tgz#c29db97a24af801101008a547caea6a33a56e467" - integrity sha512-PwuCZvHnDm10Q1zMQllpCLjtlYR1zSF+rDCRkf/TUuBC/ozz27/JkXDL+ml2nmK8IQGLGRUQKOzrQ0vVMFKvQw== +metascraper-author@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-author/-/metascraper-author-5.8.12.tgz#8e52136d983153822f93840efa2e45e51abc59f9" + integrity sha512-y23uFH9OXfYO+SgxSss6AR1ouE30N4QJ5Jty8BGzjjdz98jb2Je281wKSahVniz5sVn6RCzX8zw48BZBe/YtMw== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" lodash "~4.17.15" metascraper-clearbit-logo@^5.3.0: @@ -5832,26 +5828,26 @@ metascraper-clearbit-logo@^5.3.0: dependencies: got "~9.6.0" -metascraper-date@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-date/-/metascraper-date-5.8.7.tgz#146733ecce34f8d4a53c7c6ddcfc51c033287757" - integrity sha512-9+IslaGg+J+4cwPU5qu/MEexkoWj7sBxycmCA6vgfuCQCqNwlQ68vk2a/UVDw8OJOYjwX81JGrzxOqrQP0/kXw== +metascraper-date@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-date/-/metascraper-date-5.8.12.tgz#c2b0c584932bb93072001753e2bf86ba87f0a0e8" + integrity sha512-+84JVWRv9wgjpqmIZ0OxKU/Uighxmf+mDuZk2m/bQ4pyQ0bvqlpeCa5JxQOPgQLKyZ+hgkEskdxeGaRk/Bi46g== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" -metascraper-description@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-description/-/metascraper-description-5.8.7.tgz#e85ce218daf33b74813b1523ad7dc7dc3fb128af" - integrity sha512-KOv5gnQVvGF1CgpUczu7KJm76rWJ7SH5UFcqFST60hRNgR9xy0y3aHbVDOhZkjNN4UKqnxMF6XTS/WaQxCK/AA== +metascraper-description@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-description/-/metascraper-description-5.8.12.tgz#fa6d5a0f8f050ad19b6205529af68c6e6ed1ca2f" + integrity sha512-KEB5+urIcdqZGbLx/JULw3sjuzlfkagoEnTsOfZSCj5J6hUKpFHA5B44o9gNtNCm9miR5gfbYtbFP1XCnYji+Q== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" -metascraper-image@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-image/-/metascraper-image-5.8.7.tgz#d24697c5b5a6ba688948c48fadcb5fffeb6c703d" - integrity sha512-OMK+PFnHeavCSuEJY5tFkG5tdl/luYmPys7PKkJIwC8A8q5qoAC0InIUu+c0SDrdf4nzOj083DZTp32YQxYF5A== +metascraper-image@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-image/-/metascraper-image-5.8.12.tgz#a4b9c1cef08e86a1c5c36c0c6e132cad409a3d0b" + integrity sha512-mxzCYEKFknEG4MrRkk3KHN/LxqVnvRFwKOrfNHeRdXWSOI7ANM9SGe+5tYuXrNsONhXfMZp32PJswVqAlWsSLA== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" metascraper-lang-detector@^4.10.2: version "4.10.2" @@ -5862,19 +5858,19 @@ metascraper-lang-detector@^4.10.2: franc "~4.0.0" iso-639-3 "~1.1.0" -metascraper-lang@^5.8.9: - version "5.8.9" - resolved "https://registry.yarnpkg.com/metascraper-lang/-/metascraper-lang-5.8.9.tgz#589bac0fdc523b5b6e6317a7b6295474eedfb872" - integrity sha512-VMiU+T9LFsra/bBc0w0+fw6lk8Snb/ULoIvHUF0+5wvkv4KzQicc0z1lTAL/28Et2Xa+R5Km5A9Ts7LYuQRqVw== +metascraper-lang@^5.8.10: + version "5.8.10" + resolved "https://registry.yarnpkg.com/metascraper-lang/-/metascraper-lang-5.8.10.tgz#b8827282dea500b68e49ebbe8b0081fb6b6584d5" + integrity sha512-qydko4UkLGqTimKzT+AkcIaXOo7/GkHGtclGiLae80lHeKzI5NG7kYN4eMv1r4BfBkcluSNeJ/P532T6ZD2Y1Q== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.10" -metascraper-logo@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-logo/-/metascraper-logo-5.8.7.tgz#5efb7e6c5f91ccad812e2d9ec3facfef179f40b6" - integrity sha512-QudGVJBBeXLWU54Xw2PmnsTf+qPUnbyYaOl4aFLg2wkLLza1GbuvOYGMiH9Y8k0WcRoesi9sQk+P0a/611blew== +metascraper-logo@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-logo/-/metascraper-logo-5.8.12.tgz#db030673a94fe460a24f25547c4e2dd1a8724db7" + integrity sha512-pnYxNxRKmbfV1KIPl7DlwVtFNyTMPKcYSepxCyOA94r3sjEHHZ41FStb4vB7qAq7nUD8IfUpkONp+mDEZ3HmkA== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" metascraper-publisher@^5.8.7: version "5.8.7" @@ -5883,20 +5879,20 @@ metascraper-publisher@^5.8.7: dependencies: "@metascraper/helpers" "^5.8.7" -metascraper-soundcloud@^5.8.9: - version "5.8.9" - resolved "https://registry.yarnpkg.com/metascraper-soundcloud/-/metascraper-soundcloud-5.8.9.tgz#5d02538078114c5ab25c46df4afc3f45a94b3d7c" - integrity sha512-0otAe2E4N/KN2UqopJAM9NFZfSMyll2Q0XKhicfV/d+6Q1ERT7LWA/vwhBmxFwQzzX2mxZ8JFKeXUf6OZqEvVg== +metascraper-soundcloud@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-soundcloud/-/metascraper-soundcloud-5.8.12.tgz#6cfc2b0d317da4ac1836153cad9c9ce674b327ec" + integrity sha512-4P8HBCKh//ej3jNv33/6rLPXug51JT3DmkwUouXGfWc++MXaNmUdN+BC2oYGJhYoJCADWitNHTjgzysd3kjZ7w== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" tldts "~5.6.2" -metascraper-title@^5.8.7: - version "5.8.7" - resolved "https://registry.yarnpkg.com/metascraper-title/-/metascraper-title-5.8.7.tgz#aecbbd9515bd74d2aeafa587c83447d926508ba0" - integrity sha512-u+5KeJbsFKpi+pMnG71Gd49OLDQpkjiGIRTddhCZQhb45qHoTlGKN1nZuQ8nqJI6+ARWicFqtquomkaRXfBEnw== +metascraper-title@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-title/-/metascraper-title-5.8.12.tgz#1d57da4d0dd4566e622170630ba9b65fe26a4536" + integrity sha512-JJzJIp6O+BVFdxnYiz4lUGtzqeDLAxE5dz/Z0kj8iwJl6z/szdYTeFuI4Sc872GxN14xAgxNe1LJs2gAlZuHsg== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" lodash "~4.17.15" metascraper-url@^5.8.7: @@ -5906,35 +5902,35 @@ metascraper-url@^5.8.7: dependencies: "@metascraper/helpers" "^5.8.7" -metascraper-video@^5.8.9: - version "5.8.9" - resolved "https://registry.yarnpkg.com/metascraper-video/-/metascraper-video-5.8.9.tgz#23c0fe71fae5088bc8e11bfa537eff80658aa6d9" - integrity sha512-xaimkGz1Txsd9qHUN2U5HyFMP8tkrb5LuW8bCo+0kdTu5c00HGurvs0/BpWrTW/CzUQBNl/uEybeDXm8J++03g== +metascraper-video@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-video/-/metascraper-video-5.8.12.tgz#1e2fdaaafeae55bf9ac2e2d7c1fc567758acdb1b" + integrity sha512-9Vy/i+hPbbl5a+DQmTnUdaqQP5N1nNOzMZWdJJqLtr85JSFxayyuFzzHIN+Zk07H/FrHw1q8e6NjqD9U1Fo1Ww== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" lodash "~4.17.15" -metascraper-youtube@^5.8.9: - version "5.8.9" - resolved "https://registry.yarnpkg.com/metascraper-youtube/-/metascraper-youtube-5.8.9.tgz#595f5e384e0db519378ca2023bd8aa6603866c9d" - integrity sha512-Zuew1tLSC14ceL9ZaNvlQ4GmFopbYDalr8gL+Ofo4ha4jKyX58VaPQtmIgASAJv/jlOXd9zCwEdhNw8/YyZZWw== +metascraper-youtube@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper-youtube/-/metascraper-youtube-5.8.12.tgz#e502ee30b93ddf68e0bbfd4786ffc190b631de10" + integrity sha512-9Vqpl0VBehDjRXgocCMf5Hna8igyFfcW+KQGAAZZWPH+QLtC+Uu3nvT7ns5CQAIqCdCK+2zlpoy6up+OAJk1zw== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" get-video-id "~3.1.4" is-reachable "~4.0.0" p-locate "~4.1.0" -metascraper@^5.8.9: - version "5.8.9" - resolved "https://registry.yarnpkg.com/metascraper/-/metascraper-5.8.9.tgz#7bb468f9660bd86be8dd774cab3457d098b87e61" - integrity sha512-vuOwnSaGIG8346ZAQCE+YqvpzFVXfaMvCUdLbb8spobz7BG3945WNa43NjSl2HK5iH1WYOibvSYRZdL6wQsRJg== +metascraper@^5.8.12: + version "5.8.12" + resolved "https://registry.yarnpkg.com/metascraper/-/metascraper-5.8.12.tgz#3ee41b14864cd82a6f3cd9389c359b255ee3c2ac" + integrity sha512-EUKSva/cEl99/XxGEvdbTAG/hlG6/te/wWXM0Tqy3hX0MHjbZI569MBOjIf+mR6mj37XD817LUkrl99RgWMItw== dependencies: - "@metascraper/helpers" "^5.8.7" + "@metascraper/helpers" "^5.8.12" cheerio "~1.0.0-rc.2" cheerio-advanced-selectors "~2.0.1" lodash "~4.17.15" map-values-deep "~1.0.2" - whoops "~4.0.2" + whoops "~4.1.0" xss "~1.0.6" methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: @@ -5995,10 +5991,10 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-fn@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.0.0.tgz#0913ff0b121db44ef5848242c38bbb35d44cabde" - integrity sha512-jbex9Yd/3lmICXwYT6gA/j2mNQGU48wCh/VzRd+/Y/PjYQtlg1gLMdZqvu9s/xH7qKvngxRObl56XZR609IMbA== +mimic-fn@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.0.0.tgz#76044cfa8818bbf6999c5c9acadf2d3649b14b4b" + integrity sha512-PiVO95TKvhiwgSwg1IdLYlCTdul38yZxZMIcnDSFIBUm4BNZha2qpQ4GpJ++15bHoKDtrW2D69lMfFwdFYtNZQ== mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" @@ -6082,10 +6078,10 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -mustache@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/mustache/-/mustache-3.1.0.tgz#9fba26e7aefc5709f07ff585abb7e0abced6c372" - integrity sha512-3Bxq1R5LBZp7fbFPZzFe5WN4s0q3+gxZaZuZVY+QctYJiCiVgXHOTIC0/HgZuOPFt/6BQcx5u0H2CUOxT/RoGQ== +mustache@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-3.2.0.tgz#1c68e0bf77817a92e8a9216e35c53bbb342345f6" + integrity sha512-n5de2nQ1g2iz3PO9cmq/ZZx3W7glqjf0kavThtqfuNlZRllgU2a2Q0jWoQy3BloT5A6no7sjCTHBVn1rEKjx1Q== mute-stream@0.0.8: version "0.0.8" @@ -6152,7 +6148,7 @@ neo-async@^2.6.0: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== -neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.6: +neo4j-driver@^1.7.3, neo4j-driver@^1.7.6, neo4j-driver@~1.7.6: version "1.7.6" resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.6.tgz#eccb135a71eba9048c68717444593a6424cffc49" integrity sha512-6c3ALO3vYDfUqNoCy8OFzq+fQ7q/ab3LCuJrmm8P04M7RmyRCCnUtJ8IzSTGbiZvyhcehGK+azNDAEJhxPV/hA== @@ -6161,10 +6157,10 @@ neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.6: text-encoding-utf-8 "^1.0.2" uri-js "^4.2.2" -neo4j-graphql-js@^2.10.1: - version "2.10.1" - resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.10.1.tgz#e470d067db681bac8f4daa755f697000110aca4b" - integrity sha512-D6Gimu39lkg+3pXKWR3qEY6yMXOv/JOdKSizsYSAE73lj9CubJAYx4hdtmNXJ0Tyy+C9LxcPZwWZEzg0P9niEw== +neo4j-graphql-js@^2.10.2: + version "2.10.2" + resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.10.2.tgz#e67d1aab6441b28f276adf0f6d655720983b9b84" + integrity sha512-CgtKEgrWgSJBjuKQ5CEPt4tcG1z14oAB3UWQjX8scDlUag0iWofgzpPlrc3brn+RitfeEc3FuMSru8E9dVDJPg== dependencies: "@babel/runtime" "^7.5.5" "@babel/runtime-corejs2" "^7.5.5" @@ -6174,14 +6170,14 @@ neo4j-graphql-js@^2.10.1: lodash "^4.17.15" neo4j-driver "^1.7.3" -neode@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.3.tgz#a539830cce6f6e4825462f6cb03f2969a0003f1b" - integrity sha512-pArHG1hD2kVwrzLlz6B1+IgdOJRQj/BgR6KzH6DlVzSA6geoZRe68fbpvmOJtzyPU7iuUYxXVk87PpPM1A7dlg== +neode@^0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.6.tgz#7daf791eff6d170e52c338ea2e5cca6fdc6bfbe3" + integrity sha512-jCskCPobtHpsIIYQD72h5lRjMJEX70KwIeqgpt1VOLI+d1zJZvUlDkcOKgarAW0fmwtHIrPOP6mLPe5G/ZG9+g== dependencies: "@hapi/joi" "^15.1.0" dotenv "^4.0.0" - neo4j-driver "^1.7.5" + neo4j-driver "^1.7.6" uuid "^3.3.2" next-tick@^1.0.0: @@ -7316,10 +7312,10 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6" - integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== +resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.14.1.tgz#9e018c540fcf0c427d678b9931cbf45e984bcaff" + integrity sha512-fn5Wobh4cxbLzuHaE+nphztHy43/b++4M6SsGFC2gB8uYwf0C8LcarfCz1un7UTW8OFQg9iNjZ4xpcFVGebDPg== dependencies: path-parse "^1.0.6" @@ -7509,6 +7505,11 @@ serve-static@1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.1" set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" @@ -8245,7 +8246,7 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" -tslib@1.10.0, tslib@^1.9.0, tslib@^1.9.3: +tslib@1.10.0, tslib@^1.10.0, tslib@^1.9.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== @@ -8612,13 +8613,13 @@ which@^1.2.9, which@^1.3.0: dependencies: isexe "^2.0.0" -whoops@~4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/whoops/-/whoops-4.0.2.tgz#60e1281d47a1600f5f5013059afaad369d83e9d4" - integrity sha512-b1ofth7xMOAkukgzMhAPKBrgieGJAgKVMyu54DXAOVLmkhpQEfNKe4wS0R7LbdxIsm6FD2CFUjBOdN7Sj+zLSg== +whoops@~4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/whoops/-/whoops-4.1.0.tgz#f42e51514c7af19a9491a44cabf2712292c6a8e1" + integrity sha512-42soctqvFs9FaU1r4ZadCy2F6A9dUc4SN3ud+tbDEdmyZDTeYBgKKqtIdo6NiQlnZnJegWRCyKLk2edYH9DsHA== dependencies: - clean-stack "~2.0.0" - mimic-fn "~2.0.0" + clean-stack "~2.2.0" + mimic-fn "~3.0.0" wide-align@^1.1.0: version "1.1.3" diff --git a/locale/ru.json b/locale/ru.json new file mode 100644 index 000000000..75483edfb --- /dev/null +++ b/locale/ru.json @@ -0,0 +1,814 @@ +{ + "actions": { + "cancel": "Отменить", + "create": "Создать", + "delete": "Удалить", + "edit": "Редактировать", + "loading": "загрузка", + "loadMore": "Загрузить ещё", + "save": "Сохранить" + }, + "admin": { + "categories": { + "categoryName": "Имя", + "name": "Категории", + "postCount": "Посты" + }, + "dashboard": { + "comments": "Комментарии", + "follows": "Подписки", + "invites": "Приглашения", + "name": "Панель управления", + "notifications": "Уведомления", + "organizations": "Организации", + "posts": "Посты", + "projects": "Проекты", + "shouts": "Выкрики", + "users": "Пользователи" + }, + "donations": { + "goal": "Необходимы ежемесячные пожертвования", + "name": "Информация о пожертвованиях", + "progress": "Пожертвования собраны", + "successfulUpdate": "Информация о пожертвованиях успешно обновлена!" + }, + "hashtags": { + "name": "Хэштеги", + "nameOfHashtag": "Имя", + "number": "№", + "tagCount": "Посты", + "tagCountUnique": "Пользователи" + }, + "invites": { + "description": "Приглашения — это замечательный способ завести друзей в своей сети ...", + "name": "Пригласить пользователей", + "title": "Пригласить людей" + }, + "name": "Администрирование", + "notifications": { + "name": "Уведомления" + }, + "organizations": { + "name": "Организации" + }, + "pages": { + "name": "Страницы" + }, + "settings": { + "name": "Настройки" + }, + "tags": { + "name": "Теги", + "tagCount": "Посты", + "tagCountUnique": "Пользователи" + }, + "users": { + "empty": "Пользователи не найдены", + "form": { + "placeholder": "Электронная почта, имя или описание" + }, + "name": "Пользователи", + "table": { + "columns": { + "createdAt": "Дата создания", + "email": "Эл. почта", + "name": "Имя", + "number": "№", + "role": "Роль", + "slug": "Slug" + } + } + } + }, + "code-of-conduct": { + "consequences": { + "description": "Если участник сообщества проявляет неприемлемое поведение, ответственные операторы, модераторы и администраторы сети могут принять соответствующие меры, включая, но не ограничиваясь:", + "list": { + "0": "Просьба о немедленном прекращении неприемлемого поведения", + "1": "Блокирование или удаление комментариев", + "2": "Временное исключение из соответствующего поста или другого контента", + "3": "Блокирование или удаление контента", + "4": "Временный запрет на добавление контента", + "5": "Временное исключение из сети", + "6": "Окончательное исключение из сети", + "7": "Передача сведений о нарушениях немецкого законодательства.", + "8": "Пропаганда или поощрение такого поведения." + }, + "title": "Последствия неприемлемого поведения" + }, + "expected-behaviour": { + "description": "Мы ожидаем и требуем от всех членов сообщества предерживаться следующих правил поведения:", + "list": { + "0": "Будьте внимательны и уважительны к тому, что пишете и делаете.", + "1": "Пытайтесь сотрудничать, прежде чем возникнет конфликт.", + "2": "Воздерживайтесь от поведения и высказываний, унижающих достоинство, дискриминационного или преследующего характера.", + "3": "Будьте внимательны к своему окружению и другим участникам. Информируйте лидеров сообщества об опасных ситуациях, когда кто-либо попал в беду или нарушает настоящий Кодекс поведения, даже если они кажутся незначительными." + }, + "title": "Ожидаемое поведение" + }, + "get-help": "Если вы стали жертвой или свидетелем неприемлемого поведения или у вас возникли какие-либо другие проблемы, пожалуйста, как можно скорее сообщите об этом организатору сообщества и укажите ссылку на соответствующий контент:", + "preamble": { + "description": "Human Connection - это некоммерческая социальная сеть знаний и действий следующего поколения. Создана людьми – для людей. С открытым исходным кодом, справедливая и прозрачная. Для позитивных локальных и глобальных изменений во всех сферах жизни. Мы полностью перестраиваем публичный обмен знаниями, идеями и проектами. Функции Human Connection объединяют людей – офлайн и онлайн – так что мы можем сделать мир лучше.", + "title": "Преамбула" + }, + "purpose": { + "description": "С помощью этих правил поведения мы регулируем основные принципы поведения в нашей социальной сети. При этом Устав ООН по правам человека является нашей ориентацией и лежит в основе нашего понимания ценностей. Правила поведения служат руководящими принципами для личного выступления и общения друг с другом. Любой, кто является активным пользователем в сети Human Connection, публикует сообщения, комментирует или контактирует с другими пользователями, в том числе за пределами сети, признает эти правила поведения обязательными.", + "title": "Цель" + }, + "subheader": "социальной сети \"Human Connection gGmbH\"", + "unacceptable-behaviour": { + "description": "В нашем сообществе неприемлемо следующее поведение:", + "list": { + "0": "Дискриминационные посты, комментарии, высказывания или оскорбления, в частности, касающиеся пола, сексуальной ориентации, расы, религии, политической или мировоззренческой ориентации, или инвалидности.", + "1": "Публикация или ссылка на явно порнографические материалы.", + "2": "Прославление или умаление жестоких, или бесчеловечных актов насилия.", + "3": "Публикация персональных данных других лиц без их согласия или угрозы (\"Доксинг\").", + "4": "Преднамеренное запугивание или преследование.", + "5": "Рекламировать продукты и услуги с коммерческим намерением.", + "6": "Преступное поведение или нарушение немецкого права.", + "7": "Одобрение или поощрение недопустимого поведения." + }, + "title": "Недопустимое поведение" + } + }, + "comment": { + "content": { + "unavailable-placeholder": "...этот комментарий больше не доступен" + }, + "delete": "Удалить комментарий", + "edit": "Редактировать комментарий", + "edited": "Изменен", + "menu": { + "delete": "Удалить комментарий", + "edit": "Редактировать комментарий" + }, + "show": { + "less": "показать меньше", + "more": "показать больше" + } + }, + "common": { + "category": "Категория ::: Категории ::: Категории", + "comment": "Комментарий::: Комментарии::: Комментарии", + "letsTalk": "Давай поговорим", + "loading": "загрузка", + "loadMore": "Загрузить ещё", + "moreInfo": "Больше информации", + "name": "Имя", + "organization": "Организация ::: Организации ::: Организации", + "post": "Пост ::: Посты ::: Посты", + "project": "Проект ::: Проекты ::: Проекты", + "reportContent": "Отчет", + "shout": "Выкрик ::: Выкрики ::: Выкрики", + "tag": "Тег ::: Теги ::: Теги", + "takeAction": "Принять меры", + "user": "Пользователь ::: Пользователи ::: Пользователи", + "validations": { + "categories": "Выберите от одной то трех категорий", + "email": "должен быть корректный адрес электронной почты", + "url": "должен быть корректный URL" + }, + "versus": "Против" + }, + "components": { + "enter-nonce": { + "form": { + "description": "Откройте папку \\\"Входящие\\\" и введите код из сообщения.", + "next": "Продолжить", + "nonce": "Введите код", + "validations": { + "length": "длина должна быть 6 символов" + } + } + }, + "password-reset": { + "change-password": { + "error": "Смена пароля не удалась. Может быть, код безопасности был неправильным?", + "help": "В случае возникновения проблем, не стесняйся обращаться за помощью, отправив нам письмо по адресу:", + "success": "Смена пароля прошла успешно!" + }, + "request": { + "form": { + "description": "На указанный адрес электронной почты будет отправлено сообщение с инструкциями для сброса пароля.", + "submit": "Отправить запрос", + "submitted": "На адрес {email}<\/b>было отправлено электронное письмо с дальнейшими инструкциями" + }, + "title": "Сбросить пароль" + } + }, + "registration": { + "create-user-account": { + "error": "Не удалось создать учетную запись!", + "help": "Может быть, подтверждение было недействительным? В случае возникновения проблем, не стесняйтесь обращаться за помощью, отправив нам письмо по электронной почте:", + "success": "Учетная запись успешно создана!", + "title": "Создать учетную запись" + }, + "signup": { + "form": { + "data-privacy": "Я прочитал и понял Заявление о конфиденциальности<\/ds-text><\/a>", + "description": "Для начала работы введите свой адрес электронной почты:", + "errors": { + "email-exists": "Уже есть учетная запись пользователя с этим адресом электронной почты!", + "invalid-invitation-token": "Похоже, что приглашение уже было использовано. Ссылку из приглашения можно использовать только один раз." + }, + "invitation-code": "Код приглашения: {code}<\/b>", + "minimum-age": "Мне 18 лет или более", + "no-commercial": "У меня нет коммерческих намерений, и я не представляю коммерческое предприятие или организацию.", + "no-political": "Я не от имени какой-либо партии или политической организации в сети.", + "submit": "Создать учетную запись", + "success": "Письмо со ссылкой для завершения регистрации было отправлено на {email} <\/b>", + "terms-and-condition": "Принимаю Условия и положения<\/ds-text><\/a>." + }, + "title": "Присоединяйся к Human Connection!", + "unavailable": "К сожалению, публичная регистрация пользователей на этом сервере сейчас недоступна." + } + } + }, + "contribution": { + "categories": { + "infoSelectedNoOfMaxCategories": "Выбрано {chosen} из {max} категорий" + }, + "category": { + "name": { + "animal-protection": "Защита животных", + "art-culture-sport": "Искусство, культура и спорт", + "consumption-sustainability": "Потребление и стабильность", + "cooperation-development": "Сотрудничество и развитие", + "democracy-politics": "Демократия и политика", + "economy-finances": "Экономика и финансы", + "education-sciences": "Образование и наука", + "energy-technology": "Энергия и технологии", + "environment-nature": "Окружающая среда и природа", + "freedom-of-speech": "Свобода слова", + "global-peace-nonviolence": "Глобальный мир и борьба с насилием", + "happiness-values": "Счастье и ценности", + "health-wellbeing": "Здоровье и благополучие", + "human-rights-justice": "Права человека и справедливость", + "it-internet-data-privacy": "ИТ, интернет и конфиденциальность", + "just-for-fun": "Просто для удовольствия" + } + }, + "delete": "Удалить", + "edit": "Редактировать", + "emotions-label": { + "angry": "Возмутительно", + "cry": "Плачу", + "funny": "Смешно", + "happy": "Счастлив", + "surprised": "Удивлен" + }, + "filterALL": "Просмотреть все посты", + "filterFollow": "Показать сообщения пользователей, на которых я подписан", + "languageSelectLabel": "Язык", + "languageSelectText": "Выберите язык", + "newPost": "Создать пост", + "success": "Сохранено!", + "teaserImage": { + "cropperConfirm": "Подтвердить" + }, + "title": "Заголовок" + }, + "delete": { + "cancel": "Отменить", + "comment": { + "message": "Вы уверены, что хотите удалить комментарий \"{name}<\/b>\"?", + "success": "Комментарий успешно удален!", + "title": "Удалить комментарий", + "type": "Комментарий" + }, + "contribution": { + "message": "Вы уверены, что хотите удалить пост \"{name}<\/b>\"?", + "success": "Пост успешно удален!", + "title": "Удалить пост", + "type": "Пост" + }, + "submit": "Удалить" + }, + "disable": { + "cancel": "Отменить", + "comment": { + "message": "Вы действительно хотите отключить комментарий от «{name}<\/b>»?", + "title": "Отключить комментарий", + "type": "Комментарий" + }, + "contribution": { + "message": "Вы действительно хотите отключить пост «{name}<\/b>»?", + "title": "Отключить пост", + "type": "Пост" + }, + "submit": "Отключить", + "success": "Успешно отключен", + "user": { + "message": "Вы действительно хотите отключить пользователя «{name}<\/b>»?", + "title": "Отключить пользователя", + "type": "Пользователь" + } + }, + "donations": { + "amount-of-total": "{amount} из {total} € собрано", + "donate-now": "Пожертвуйте сейчас", + "donations-for": "Пожертвования для" + }, + "editor": { + "embed": { + "always_allow": "Всегда отображать содержимое сторонних производителей (эту настройку можно изменить в любое время).", + "data_privacy_info": "Ваши данные еще не были переданы третьим лицам. Если вы воспроизведёте это видео, следующий провайдер, вероятно, зарегистрирует ваши данные пользователя:", + "data_privacy_warning": "Предупреждение о конфиденциальности данных!", + "play_now": "Смотреть сейчас" + }, + "hashtag": { + "addHashtag": "Новый хэштег", + "addLetter": "Введите букву", + "noHashtagsFound": "Хэштеги не найдены" + }, + "mention": { + "noUsersFound": "Пользователи не найдены" + }, + "placeholder": "Поделитесь своими вдохновляющими мыслями ..." + }, + "filter-menu": { + "clearSearch": "Очистить поиск", + "hashtag-search": "Поиск по #{hashtag}", + "title": "Ваш фильтр пузыря" + }, + "filter-posts": { + "categories": { + "all": "Все", + "header": "Категории" + }, + "followers": { + "label": "Мои подписки" + }, + "general": { + "header": "Другие фильтры" + }, + "language": { + "all": "Все", + "header": "Языки" + } + }, + "followButton": { + "follow": "Подписаться", + "following": "Вы подписаны" + }, + "index": { + "change-filter-settings": "Измените настройки фильтра, чтобы получить больше результатов.", + "no-results": "Посты не найдены." + }, + "login": { + "copy": "Авторизуйтесь, если у вас уже есть учетная запись Human Connection.", + "email": "Электронная почта", + "failure": "Неверный адрес электронной почты или пароль.", + "forgotPassword": "Забыли пароль?", + "hello": "Здравствуйте", + "login": "Вход", + "logout": "Выйти", + "moreInfo": "Что такое Human Connection?", + "moreInfoHint": "на страницу проекта", + "moreInfoURL": "https:\/\/human-connection.org\/en\/", + "no-account": "У вас нет аккаунта?", + "password": "Пароль", + "register": "Зарегистрируйтесь", + "success": "Вы вошли в систему!" + }, + "maintenance": { + "explanation": "В данный момент мы проводим плановое техническое обслуживание, пожалуйста, повторите попытку позже.", + "questions": "Любые вопросы или сообщения о проблемах отправляйте на электронную почту", + "title": "Human Connection на техническом обслуживании" + }, + "moderation": { + "name": "Модерация", + "reports": { + "author": "Автор", + "content": "Содержа́ние", + "decideButton": "Подтвердить", + "decided": "Решил", + "decideModal": { + "cancel": "Отменить", + "Comment": { + "disable": { + "message": "Вы действительно хотите, чтобы комментарий \"{name}<\/b>\" остановиться и отключен<\/b>?", + "title": "Окончательно отключить комментарий" + }, + "enable": { + "message": "Вы действительно хотите, чтобы комментарий \"{name}<\/b>\" остановиться и включен<\/b>?", + "title": "Окончательно включить комментарий" + } + }, + "Post": { + "disable": { + "message": "Вы действительно хотите, чтобы пост \"{name}<\/b>\" остановиться и отключен<\/b>?", + "title": "Окончательно отключить пост" + }, + "enable": { + "message": "Вы действительно хотите, чтобы пост \"{name}<\/b>\" остановиться и включен<\/b>?", + "title": "Окончательно включить пост" + } + }, + "submit": "Подтвердить решение", + "User": { + "disable": { + "message": "Вы действительно хотите, чтобы пользователь \"{name}<\/b>\" остановиться и отключен<\/b>?", + "title": "Окончательно отключить пользователя" + }, + "enable": { + "message": "Вы уверены, что хотите поделиться пользователем \"{name}<\/b>\"?", + "title": "Окончательно включить пост" + } + } + }, + "decision": "Решение", + "DecisionSuccess": "Решил успешно!", + "disabled": "Отключен", + "disabledAt": "Отключено на", + "disabledBy": "Отключил(а)", + "empty": "Поздравляю, модерировать нечего.", + "enabled": "Включен", + "enabledAt": "Включено на", + "enabledBy": "Включено с", + "filterLabel": { + "all": "Все", + "closed": "Закрыто", + "reviewed": "Рассмотренный", + "unreviewed": "Нерассмотренный" + }, + "moreDetails": "Посмотреть подробности", + "name": "Отчеты", + "noDecision": "Нет решения!", + "numberOfUsers": "{count} пользователи", + "previousDecision": "Предыдущее решение:", + "reasonCategory": "Категория", + "reasonDescription": "Описание", + "reportedOn": "Дата", + "reporter": "Сообщил(а)", + "status": "Текущее состояние", + "submitter": "Сообщил(а)" + } + }, + "notifications": { + "comment": "Комментарий", + "content": "Контент", + "empty": "Извините, на данный момент у вас нет уведомлений.", + "filterLabel": { + "all": "Все", + "read": "Прочитанные", + "unread": "Непрочитанные" + }, + "pageLink": "Все уведомления", + "post": "Пост", + "reason": { + "commented_on_post": "Комментарий к посту...", + "mentioned_in_comment": "Упоминание в комментарии....", + "mentioned_in_post": "Упоминание в посте...." + }, + "title": "Уведомления", + "user": "Пользователь" + }, + "post": { + "comment": { + "submit": "Комментировать", + "submitted": "Комментарий отправлен", + "updated": "Изменения сохраненные" + }, + "edited": "Изменен", + "menu": { + "delete": "Удалить пост", + "edit": "Редактировать пост", + "pin": "Закрепить пост", + "pinnedSuccessfully": "Пост больше не закреплен!", + "unpin": "Открепить пост", + "unpinnedSuccessfully": "Пост успешно не закреплено!" + }, + "moreInfo": { + "description": "Здесь содержится дополнительная информация по теме.", + "name": "Дополнительная информация", + "title": "Дополнительная информация", + "titleOfCategoriesSection": "Категории", + "titleOfHashtagsSection": "Хэштеги", + "titleOfRelatedContributionsSection": "Похожие посты" + }, + "name": "Пост", + "pinned": "Объявление", + "takeAction": { + "name": "Действовать" + } + }, + "profile": { + "commented": "Прокомментированные", + "follow": "Подписаться", + "followers": "Подписчики", + "following": "Подписки", + "invites": { + "description": "Введите адрес электронной почты для приглашения.", + "emailPlaceholder": "Электронная почта для приглашения", + "title": "Пригласите кого-нибудь в Human Connection!" + }, + "memberSince": "Участник с", + "name": "Мой профиль", + "network": { + "andMore": "и ещё {number} человек... ::: и ещё {number} человека... ::: и ещё {number} человек...", + "followedBy": "ваши подписчики:", + "followedByNobody": "у вас нет подписчиков.", + "following": "подписан на:", + "followingNobody": "ни на кого не подписан.", + "title": "Сеть" + }, + "shouted": "С выкриками", + "socialMedia": "Где еще я могу найти", + "userAnonym": "Анонимный" + }, + "quotes": { + "african": { + "author": "Африканская пословица", + "quote": "Много маленьких людей делают много маленьких вещей во многих маленьких местах, что может изменить мир до неузнаваемости." + } + }, + "release": { + "cancel": "Отменить", + "comment": { + "error": "Вы уже сообщили о комментарии!", + "message": "Вы уверены, что хотите показать комментарий \"{name}<\/b>\"?", + "title": "Показать комментарий", + "type": "Комментарий" + }, + "contribution": { + "error": "Вы уже сообщили о посте!", + "message": "Вы уверены, что хотите показать пост \"{name}<\/b>\"?", + "title": "Показать пост", + "type": "Пост" + }, + "submit": "Показать", + "success": "Успешно показан!", + "user": { + "error": "Вы уже сообщили о пользователе!", + "message": "Вы уверены, что хотите показать пользователя \"{name}<\/b>\"?", + "title": "Показать пользователя", + "type": "Пользователь" + } + }, + "report": { + "cancel": "Отменить", + "comment": { + "error": "Вы уже сообщили о посте!", + "message": "Вы действительно хотите сообщить о посте \" {name} <\/b>\"?", + "title": "Пожаловаться на комментарий", + "type": "Комментарий" + }, + "contribution": { + "error": "Вы уже сообщили о посте!", + "message": "Вы действительно хотите сообщить о посте \"{name}<\/b>\"?", + "title": "Пожаловаться на пост", + "type": "Пожаловаться на пост" + }, + "reason": { + "category": { + "invalid": "Пожалуйста, выберите подходящую категорию", + "label": "Выберите категорию:", + "options": { + "advert_products_services_commercial": "Реклама продуктов и услуг с коммерческим намерением.", + "criminal_behavior_violation_german_law": "Уголовное поведение или нарушении немецкого права.", + "discrimination_etc": "Дискриминационные посты, комментарии, заявления или оскорбления.", + "doxing": "Публикация персональных данных других лиц без их согласия или угроза публикации (\"Доксинг\").", + "glorific_trivia_of_cruel_inhuman_acts": "Прославление или умаление жестоких, или бесчеловечных актов насилия.", + "intentional_intimidation_stalking_persecution": "Преднамеренное запугивание или преследование.", + "other": "Другое ...", + "pornographic_content_links": "Публикация или ссылка на явно порнографический материал." + }, + "placeholder": "Категория ..." + }, + "description": { + "label": "Пожалуйста, объясните, почему хотите об этом сообщить?", + "placeholder": "Дополнительная информация ..." + } + }, + "submit": "Отправить", + "success": "Спасибо за сообщение!", + "user": { + "error": "Вы уже сообщили о пользователе!", + "message": "Вы действительно хотите сообщить о пользователе \"{name}<\/b>\"?", + "title": "Пожаловаться на пользователя", + "type": "Пользователь" + } + }, + "search": { + "failed": "Ничего не найдено", + "hint": "Что вы хотите найти?", + "placeholder": "Поиск" + }, + "settings": { + "blocked-users": { + "block": "Блокировать", + "columns": { + "name": "Имя", + "slug": "Псевдоним", + "unblock": "Разблокировать" + }, + "empty": "Вы пока никого не блокировали.", + "explanation": { + "closing": "На данный момент этого должно быть достаточно, чтобы заблокированные пользователи больше вас не беспокоили.", + "intro": "Если блокируете другого пользователя, происходит следующее:", + "notifications": "Заблокированные пользователи больше не будут получать уведомления об упоминаниях в ваших постах.", + "search": "Посты заблокированных пользователей не отображаются в результатах поиска.", + "their-perspective": "И наоборот — заблокированный пользователь больше не видит ваши посты в своей ленте.", + "your-perspective": "Посты заблокированного пользователя не отображаются в персональной ленте." + }, + "how-to": "Вы можете блокировать других пользователей на странице их профиля с помощью меню профиля.", + "name": "Заблокированные пользователи", + "unblock": "Разблокировать пользователей", + "unblocked": "{name} - снова разблокирован" + }, + "data": { + "labelBio": "О себе", + "labelCity": "Город или регион", + "labelName": "Имя", + "labelSlug": "Уникальное имя пользователя", + "name": "Персональные данные", + "namePlaceholder": "Маша Медведева", + "success": "Персональные данные были успешно обновлены!" + }, + "delete": { + "name": "Удалить аккаунт" + }, + "deleteUserAccount": { + "accountDescription": "Обратите внимание, что ваши посты и комментарии важны для сообщества. Если вы все равно хотите их удалить, то вы должны отметить соответствующие опции ниже.", + "accountWarning": "Вы НЕ СМОЖЕТЕ<\/b> восстановить свой аккаунт, посты или комментарии после удаления.", + "commentedCount": "Удалить мои комментарии: {count}", + "contributionsCount": "Удалить мои посты: {count}", + "name": "Удалить данные", + "pleaseConfirm": "Разрушительное действие!<\/b> Введите {confirm}<\/b> для подтверждения.", + "success": "Аккаунт успешно удален!" + }, + "download": { + "name": "Скачать данные" + }, + "email": { + "change-successful": "Адрес электронной почты был успешно изменен.", + "labelEmail": "Адрес электронной почты", + "labelNewEmail": "Новый адрес электронной почты", + "labelNonce": "Введите свой код", + "name": "Электронная почта", + "submitted": "Электронное письмо с подтверждением отправлено на {email}<\/b>.", + "success": "Новый адрес электронной почты был зарегистрирован.", + "validation": { + "same-email": "Это текущий адрес электронной почты." + }, + "verification-error": { + "explanation": "Причины могут быть разными:", + "message": "Адрес электронной почты не может быть изменен.", + "reason": { + "invalid-nonce": "Правильно ли указан код подтверждения?", + "no-email-request": "Вы уверены, что отправляли запрос на изменение своего адреса электронной почты?" + }, + "support": "Если проблема сохраняется, пожалуйста, свяжитесь с нами по электронной почте" + } + }, + "embeds": { + "info-description": "Вот список сторонних провайдеров, чей контент может отображаться в форме вставок кода, например, в виде встроенных видео:", + "name": "Сторонний контент", + "status": { + "change": { + "allow": "Конечно.", + "deny": "Нет, не надо", + "question": "Вы хотите, чтобы вставки кода сторонних провайдеров всегда отображались?" + }, + "description": "Значение по умолчанию -", + "disabled": { + "off": "сначала не отображать вставки кода сторонних провайдеров", + "on": "сразу отображать вставки кода сторонних провайдеров" + } + } + }, + "invites": { + "name": "Приглашения" + }, + "languages": { + "name": "Языки" + }, + "name": "Настройки", + "organizations": { + "name": "Мои организации" + }, + "privacy": { + "make-shouts-public": "Публиковать в моем публичном профиле статьи в которых я участвовал", + "name": "Конфиденциальность", + "success-update": "Настройки приватности сохранены" + }, + "security": { + "change-password": { + "button": "Изменить пароль", + "label-new-password": "Новый пароль", + "label-new-password-confirm": "Подтверждение пароля", + "label-old-password": "Старый пароль", + "message-new-password-confirm-required": "Требуется подтверждение пароля", + "message-new-password-missmatch": "Пароли не совпадают", + "message-new-password-required": "Требуется новый пароль", + "message-old-password-required": "Требуется свой старый пароль", + "passwordSecurity": "Безопасность пароля", + "passwordStrength0": "Очень небезопасный", + "passwordStrength1": "Небезопасный", + "passwordStrength2": "Посредственный", + "passwordStrength3": "Надежный", + "passwordStrength4": "Очень надежный", + "success": "Пароль успешно изменен!" + }, + "name": "Безопасность" + }, + "social-media": { + "name": "Социальные Медиа", + "placeholder": "Ссылка на профиль социальной сети", + "requireUnique": "Ссылка уже существует", + "submit": "Добавить ссылку", + "successAdd": "Добавлены социальные меди. Профиль обновлен!", + "successDelete": "Социальные Меди удалены. Профиль обновлен!" + }, + "validation": { + "slug": { + "alreadyTaken": "Это имя пользователя уже занято.", + "regex": "Допускаются только строчные буквы, цифры, подчеркивания или дефисы." + } + } + }, + "shoutButton": { + "shouted": "выкрикнули" + }, + "site": { + "back-to-login": "Вернуться на страницу входа", + "bank": "банковский счет", + "changelog": "Изменения", + "code-of-conduct": "Кодекс поведения", + "contact": "Контакт", + "data-privacy": "Конфиденциальность", + "director": "Управляющий директор", + "error-occurred": "Произошла ошибка.", + "faq": "ЧаВо (FAQ)", + "germany": "Германия", + "imprint": "Импрессум", + "made": "Сделано с ❤", + "register": "Регистрационный номер", + "responsible": "ответственный за содержание этой страницы (§ 55 Abs. 2 RStV)", + "taxident": "UST-ID. в соответствии с §27a Закона о налоге с продаж Германии:", + "termsAndConditions": "Условия и положения", + "thanks": "Спасибо!", + "tribunal": "Суд регистрации" + }, + "store": { + "posts": { + "orderBy": { + "newest": { + "label": "Сначала новые" + }, + "oldest": { + "label": "Сначала старые" + } + } + } + }, + "termsAndConditions": { + "addition": { + "description": " https:\/\/human-connection.org\/events\/ <\/a>", + "title": "Кроме того, мы регулярно проводим мероприятия, где вы также можете\\nподелиться своими впечатлениями и задать вопросы. Информацию о текущих событиях можно найти здесь:" + }, + "agree": "Я согласен(на)!", + "code-of-conduct": { + "description": "Наш кодекс поведения служит руководством для личного поведения и взаимодействия друг с другом. Каждый пользователь социальной сети Human Connection, который пишет статьи, комментирует или вступает в контакт с другими пользователями, даже за пределами сети, признает эти правила поведения обязательными. https:\/\/alpha.human-connection.org\/code-of-conduct<\/a>", + "title": "Кодекс поведения" + }, + "errors-and-feedback": { + "description": "Мы прилагаем все усилия для обеспечения безопасности и доступности нашей сети и данных. Каждый новый выпуск программного обеспечения проходит как автоматическое, так и ручное тестирование. Однако могут возникнуть непредвиденные ошибки. Поэтому мы благодарны за любые обнаруженные ошибки. Вы можете сообщить о любых обнаруженных ошибках, отправив электронное письмо в службу поддержки по адресу support@human-connection.org", + "title": "Ошибки и обратная связь" + }, + "help-and-questions": { + "description": "Для справки и вопросов мы собрали для вас исчерпывающую подборку часто задаваемых вопросов и ответов (FAQ). Вы можете найти их здесь: https:\/\/support.human-connection.org\/kb\/ <\/a>", + "title": "Помощь и вопросы" + }, + "moderation": { + "description": "Пока наши финансовые возможности не позволяют нам реализовать полноценную систему модерации, поэтому мы осуществляем упрощенную модерацию собственными силами и с помощью волонтёров. Мы специально обучаем этих модераторов, поэтому только они принимают соответствующие решения. Модераторы действуют анонимно. Вы можете сообщать нам о постах, комментариях и пользователях (например, если они предоставляют информацию в своем профиле или имеют изображения, которые нарушают настоящие Условия использования). При обращении вы можете указать причину и дать краткое пояснение. Мы рассмотрим обращение и применим санкции в случае необходимости, например, путем блокировки постов, комментариев или пользователей. К сожалению, в настоящее время ни вы ни пострадавший пользователь не получите от нас обратной связи, но мы планируем ряд улучшений в этом направлении. Несмотря на это, мы оставляем за собой право на применение санкций по причинам, которые не могут быть или ещё не указаны в нашем Кодексе поведения или настоящих Условиях использования.", + "title": "Модерация" + }, + "newTermsAndConditions": "Новые условия и положения", + "no-commercial-use": { + "description": "Использование Human Connection сети не допускается в коммерческих целях. Это включает, но не ограничивается рекламой продуктов с коммерческими целями, размещением партнерских ссылок, прямым привлечением пожертвований или предоставлением финансовой поддержки для целей, которые не признаются благотворительными для целей налогообложения.", + "title": "Нет коммерческого использования" + }, + "privacy-statement": { + "description": "Наша сеть — это социальная сеть знаний и действий. Поэтому для нас особенно важно, чтобы как можно больше контента было общедоступным. В процессе развития нашей сети будет добавлено больше возможностей для управления видимостью личных данных. Об этих новых функциях мы сообщим дополнительно. В противном случае вы должны думать о том, какие личные данные вы раскрываете о себе (или других). Это особенно актуально для содержания постов и комментариев, поскольку они имеют в основном общедоступный характер. Позже появятся возможности ограничения видимости вашего профиля. Часть условий использования — это наша политика конфиденциальности, которая информирует вас об обработке персональных данных в нашей сети: https:\/\/human-connection.org\/datenschutz\/#netzwerk<\/a> или https:\/\/human-connection.org\/datenschutz<\/a>. Наше заявление о конфиденциальности корректируется в соответствии с законодательством и характеристиками нашей сети и является действительной в настоящей версии.", + "title": "Заявление о конфиденциальности" + }, + "terms-of-service": { + "description": "Следующие условия использования являются основой для использования нашей сети. При регистрации вы должны принять их, а мы при необходимости сообщим вам об изменениях. Сеть Human Connection работает в Германии и поэтому регулируется немецким законодательством. Место юрисдикции - Kirchheim \/ Teck. Подробности в выходных данных: https:\/\/human-connection.org\/en\/imprint<\/a>.", + "title": "Условия обслуживания" + }, + "termsAndConditionsConfirmed": "Я прочитал(а) и подтверждаю Условия и положения<\/a>.", + "termsAndConditionsNewConfirm": "Я прочитал(а) и согласен(на) с новыми условиями.", + "termsAndConditionsNewConfirmText": "Пожалуйста, ознакомьтесь с новыми условиями использования!", + "use-and-license": { + "description": "Если размещаемый в сети контент защищен правами на интеллектуальную собственность, вы предоставляете нам неисключительную, передаваемую, сублицензируемую и всемирную лицензию на использование этого контента для публикации в нашей сети. Эта лицензия заканчивается, как только вы удаляете свой контент или учетную запись. Помните, что другие пользователи могут продолжать делиться вашим контентом, и мы не можем его удалить.", + "title": "Использование и лицензия" + } + }, + "user": { + "avatar": { + "submitted": "Успешная загрузка!" + } + } +} \ No newline at end of file diff --git a/neo4j/db_manipulation/add_image_aspect_ratio.sh b/neo4j/db_manipulation/add_image_aspect_ratio.sh index 7fe2c5871..8e2a16a01 100755 --- a/neo4j/db_manipulation/add_image_aspect_ratio.sh +++ b/neo4j/db_manipulation/add_image_aspect_ratio.sh @@ -12,19 +12,11 @@ do sleep 1 done -shopt -s nullglob -for image in uploads/*; do - [ -e "$image" ] || continue - IMAGE_WIDTH=$( identify -format '%w' "$image" ) - IMAGE_HEIGHT=$( identify -format '%h' "$image" ) - IMAGE_ASPECT_RATIO=$(echo | awk "{ print ${IMAGE_WIDTH}/${IMAGE_HEIGHT}}") - - - echo "$image" - echo "$IMAGE_ASPECT_RATIO" - echo " - match (post:Post {image: '/"${image}"'}) - set post.imageAspectRatio = "${IMAGE_ASPECT_RATIO}" - return post; - " | cypher-shell -done +echo " + CALL apoc.periodic.iterate(' + CALL apoc.load.csv("out.csv") yield map as row return row + ',' + MATCH (post:Post) where post.image = row.image + set post.imageAspectRatio = row.aspectRatio + ', {batchSize:10000, iterateList:true, parallel:true}); +" | cypher-shell diff --git a/package.json b/package.json index 8d8c53f71..51d1993ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "human-connection", - "version": "0.1.12", + "version": "0.1.13", "description": "Fullstack and API tests with cypress and cucumber for Human Connection", "author": "Human Connection gGmbh", "license": "MIT", @@ -21,16 +21,16 @@ "version": "auto-changelog -p" }, "devDependencies": { - "@babel/core": "^7.7.5", - "@babel/preset-env": "^7.7.6", + "@babel/core": "^7.7.7", + "@babel/preset-env": "^7.7.7", "@babel/register": "^7.7.4", "auto-changelog": "^1.16.2", "bcryptjs": "^2.4.3", "codecov": "^3.6.1", "cross-env": "^6.0.3", "cucumber": "^6.0.5", - "cypress": "^3.7.0", - "cypress-cucumber-preprocessor": "^1.18.0", + "cypress": "^3.8.0", + "cypress-cucumber-preprocessor": "^1.19.0", "cypress-file-upload": "^3.5.1", "cypress-plugin-retries": "^1.5.0", "date-fns": "^2.8.1", diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 37a31d6f4..a20ca4111 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:13.1.0-alpine as base +FROM node:13.4.0-alpine as base LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 diff --git a/webapp/Dockerfile.maintenance b/webapp/Dockerfile.maintenance index 7195d0f1c..2efec964b 100644 --- a/webapp/Dockerfile.maintenance +++ b/webapp/Dockerfile.maintenance @@ -1,4 +1,4 @@ -FROM node:13.1.0-alpine as build +FROM node:13.4.0-alpine as build LABEL Description="Maintenance page of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" EXPOSE 3000 diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js index 8f93aa4a4..7894dea0e 100644 --- a/webapp/components/ContentMenu/ContentMenu.spec.js +++ b/webapp/components/ContentMenu/ContentMenu.spec.js @@ -22,10 +22,6 @@ describe('ContentMenu.vue', () => { locale: () => 'en', }, $router: { - resolve: jest.fn(obj => { - obj.href = '/post/edit/d23a4265-f5f7-4e17-9f86-85f714b4b9f8' - return obj - }), push: jest.fn(), }, } @@ -76,7 +72,7 @@ describe('ContentMenu.vue', () => { .at(0) .find('span.ds-menu-item-link') .attributes('to'), - ).toBe('/post/edit/d23a4265-f5f7-4e17-9f86-85f714b4b9f8') + ).toBe('/post-edit-id') }) it('can delete the contribution', () => { diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index d4c567437..25192c21e 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -17,7 +17,7 @@ @click.stop.prevent="openItem(item.route, toggleMenu)" > - {{ item.route.name }} + {{ item.route.label }} @@ -58,17 +58,15 @@ export default { if (this.resourceType === 'contribution') { if (this.isOwner) { routes.push({ - name: this.$t(`post.menu.edit`), - path: this.$router.resolve({ - name: 'post-edit-id', - params: { - id: this.resource.id, - }, - }).href, + label: this.$t(`post.menu.edit`), + name: 'post-edit-id', + params: { + id: this.resource.id, + }, icon: 'edit', }) routes.push({ - name: this.$t(`post.menu.delete`), + label: this.$t(`post.menu.delete`), callback: () => { this.openModal('confirm', 'delete') }, @@ -79,7 +77,7 @@ export default { if (this.isAdmin) { if (!this.resource.pinnedBy) { routes.push({ - name: this.$t(`post.menu.pin`), + label: this.$t(`post.menu.pin`), callback: () => { this.$emit('pinPost', this.resource) }, @@ -87,7 +85,7 @@ export default { }) } else { routes.push({ - name: this.$t(`post.menu.unpin`), + label: this.$t(`post.menu.unpin`), callback: () => { this.$emit('unpinPost', this.resource) }, @@ -99,14 +97,14 @@ export default { if (this.isOwner && this.resourceType === 'comment') { routes.push({ - name: this.$t(`comment.menu.edit`), + label: this.$t(`comment.menu.edit`), callback: () => { this.$emit('showEditCommentMenu', true) }, icon: 'edit', }) routes.push({ - name: this.$t(`comment.menu.delete`), + label: this.$t(`comment.menu.delete`), callback: () => { this.openModal('confirm', 'delete') }, @@ -116,7 +114,7 @@ export default { if (!this.isOwner) { routes.push({ - name: this.$t(`report.${this.resourceType}.title`), + label: this.$t(`report.${this.resourceType}.title`), callback: () => { this.openModal('report') }, @@ -127,7 +125,7 @@ export default { if (!this.isOwner && this.isModerator) { if (!this.resource.disabled) { routes.push({ - name: this.$t(`disable.${this.resourceType}.title`), + label: this.$t(`disable.${this.resourceType}.title`), callback: () => { this.openModal('disable') }, @@ -135,7 +133,7 @@ export default { }) } else { routes.push({ - name: this.$t(`release.${this.resourceType}.title`), + label: this.$t(`release.${this.resourceType}.title`), callback: () => { this.openModal('release') }, @@ -147,14 +145,14 @@ export default { if (this.resourceType === 'user') { if (this.isOwner) { routes.push({ - name: this.$t(`settings.name`), + label: this.$t(`settings.name`), path: '/settings', icon: 'edit', }) } else { if (this.resource.isBlocked) { routes.push({ - name: this.$t(`settings.blocked-users.unblock`), + label: this.$t(`settings.blocked-users.unblock`), callback: () => { this.$emit('unblock', this.resource) }, @@ -162,7 +160,7 @@ export default { }) } else { routes.push({ - name: this.$t(`settings.blocked-users.block`), + label: this.$t(`settings.blocked-users.block`), callback: () => { this.$emit('block', this.resource) }, @@ -186,7 +184,7 @@ export default { if (route.callback) { route.callback() } else { - this.$router.push(route.path) + this.$router.push(route) } toggleMenu() }, diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 8c50f30b6..2f0f2e30d 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -200,6 +200,7 @@ describe('ContributionForm.vue', () => { imageUpload: null, imageAspectRatio: null, image: null, + imageBlurred: false, }, } postTitleInput = wrapper.find('.ds-input') diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index ec9fe9616..92000edf2 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -10,6 +10,7 @@ + + + @@ -38,23 +55,21 @@ {{ form.title.length }}/{{ formSchema.title.max }} - - - - - {{ contentLength }} - - - - {{ contentLength }} - - - + + + + {{ contentLength }} + + + + {{ contentLength }} + + @@ -82,6 +97,7 @@ +
this.contribution.language === o.value) : null form.categoryIds = this.categoryIds(this.contribution.categories) + form.blurImage = this.contribution.imageBlurred } + return { form, formSchema: { @@ -169,6 +189,7 @@ export default { }, }, language: { required: true }, + blurImage: { required: false }, }, languageOptions, id, @@ -177,6 +198,7 @@ export default { users: [], contentMin: 3, hashtags: [], + elem: null, } }, computed: { @@ -197,6 +219,7 @@ export default { teaserImage, imageAspectRatio, categoryIds, + blurImage, } = this.form this.loading = true this.$apollo @@ -210,6 +233,7 @@ export default { language, image, imageUpload: teaserImage, + imageBlurred: blurImage, imageAspectRatio, }, }) @@ -275,28 +299,35 @@ export default { } - diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/Editor.vue index 234d94d2d..6c8a1908a 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/Editor.vue @@ -24,7 +24,6 @@ import { Editor, EditorContent } from 'tiptap' import { History } from 'tiptap-extensions' import linkify from 'linkify-it' -import stringHash from 'string-hash' import { replace, build } from 'xregexp/xregexp-all.js' import * as key from '../../constants/keycodes' @@ -108,17 +107,6 @@ export default { }, }, watch: { - value: { - immediate: true, - handler: function(content, old) { - const contentHash = stringHash(content) - if (!content || contentHash === this.lastValueHash) { - return - } - this.lastValueHash = contentHash - this.$nextTick(() => this.editor.setContent(content)) - }, - }, placeholder: { immediate: true, handler: function(val) { @@ -129,7 +117,7 @@ export default { }, }, }, - created() { + mounted() { this.editor = new Editor({ content: this.value || '', doc: this.doc, @@ -247,11 +235,7 @@ export default { }, onUpdate(e) { const content = e.getHTML() - const contentHash = stringHash(content) - if (contentHash !== this.lastValueHash) { - this.lastValueHash = contentHash - this.$emit('input', content) - } + this.$emit('input', content) }, toggleLinkInput(attrs, element) { if (!this.isLinkInputActive && attrs && element) { 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 @@ diff --git a/webapp/components/PostCard/PostCard.vue b/webapp/components/PostCard/PostCard.vue index d5afe90e1..f9c1fa325 100644 --- a/webapp/components/PostCard/PostCard.vue +++ b/webapp/components/PostCard/PostCard.vue @@ -2,7 +2,12 @@ {}, }, }, + mounted() { + const width = this.$el.offsetWidth + const height = Math.min(width / this.post.imageAspectRatio, 2000) + const imageElement = this.$el.querySelector('.ds-card-image') + if (imageElement) { + imageElement.style.height = `${height}px` + } + }, computed: { ...mapGetters({ user: 'auth/user', @@ -143,23 +156,26 @@ export default { }, } - - diff --git a/webapp/components/TeaserImage/TeaserImage.vue b/webapp/components/TeaserImage/TeaserImage.vue index 95d94d70f..a08b9e0ef 100644 --- a/webapp/components/TeaserImage/TeaserImage.vue +++ b/webapp/components/TeaserImage/TeaserImage.vue @@ -140,7 +140,7 @@ export default {