Merge branch 'master' into 1746-Blur_explicit_Image_Content

This commit is contained in:
Alexander Friedland 2019-12-17 07:39:26 +01:00 committed by GitHub
commit ed617f8c83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 2323 additions and 963 deletions

View File

@ -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). 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) #### [v0.1.12](https://github.com/Human-Connection/Human-Connection/compare/v0.1.10...v0.1.12)
> 10 December 2019 > 10 December 2019

View File

@ -1 +1 @@
0.1.12 0.1.13

View File

@ -35,7 +35,7 @@
"@hapi/joi": "^16.1.8", "@hapi/joi": "^16.1.8",
"@sentry/node": "^5.10.2", "@sentry/node": "^5.10.2",
"apollo-cache-inmemory": "~1.6.3", "apollo-cache-inmemory": "~1.6.3",
"apollo-client": "~2.6.4", "apollo-client": "~2.6.8",
"apollo-link-context": "~1.0.19", "apollo-link-context": "~1.0.19",
"apollo-link-http": "~1.5.16", "apollo-link-http": "~1.5.16",
"apollo-server": "~2.9.13", "apollo-server": "~2.9.13",
@ -63,15 +63,15 @@
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.3", "merge-graphql-schemas": "^1.7.3",
"metascraper": "^5.8.9", "metascraper": "^5.8.9",
"metascraper-audio": "^5.8.7", "metascraper-audio": "^5.8.10",
"metascraper-author": "^5.8.7", "metascraper-author": "^5.8.7",
"metascraper-clearbit-logo": "^5.3.0", "metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.8.7", "metascraper-date": "^5.8.7",
"metascraper-description": "^5.8.7", "metascraper-description": "^5.8.10",
"metascraper-image": "^5.8.7", "metascraper-image": "^5.8.7",
"metascraper-lang": "^5.8.9", "metascraper-lang": "^5.8.9",
"metascraper-lang-detector": "^4.10.2", "metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.8.7", "metascraper-logo": "^5.8.10",
"metascraper-publisher": "^5.8.7", "metascraper-publisher": "^5.8.7",
"metascraper-soundcloud": "^5.8.9", "metascraper-soundcloud": "^5.8.9",
"metascraper-title": "^5.8.7", "metascraper-title": "^5.8.7",
@ -81,10 +81,10 @@
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"mustache": "^3.1.0", "mustache": "^3.1.0",
"neo4j-driver": "~1.7.6", "neo4j-driver": "~1.7.6",
"neo4j-graphql-js": "^2.10.0", "neo4j-graphql-js": "^2.10.1",
"neode": "^0.3.3", "neode": "^0.3.3",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.4.1", "nodemailer": "^6.4.2",
"nodemailer-html-to-text": "^3.1.0", "nodemailer-html-to-text": "^3.1.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
"request": "~2.88.0", "request": "~2.88.0",
@ -115,11 +115,11 @@
"eslint-plugin-import": "~2.19.1", "eslint-plugin-import": "~2.19.1",
"eslint-plugin-jest": "~23.1.1", "eslint-plugin-jest": "~23.1.1",
"eslint-plugin-node": "~10.0.0", "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-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.1", "eslint-plugin-standard": "~4.0.1",
"jest": "~24.9.0", "jest": "~24.9.0",
"nodemon": "~2.0.1", "nodemon": "~2.0.2",
"prettier": "~1.19.1", "prettier": "~1.19.1",
"supertest": "~4.0.2" "supertest": "~4.0.2"
} }

View File

@ -11,27 +11,28 @@ export default async (driver, authorizationHeader) => {
} catch (err) { } catch (err) {
return null 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() 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 { try {
result = await session.run(query, { id }) const [currentUser] = await writeTxResultPromise
if (!currentUser) return null
return {
token,
...currentUser,
}
} finally { } finally {
session.close() session.close()
} }
const [currentUser] = await result.records.map(record => {
return record.get('user')
})
if (!currentUser) return null
return {
token,
...currentUser,
}
} }

View File

@ -2,30 +2,23 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return 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() const session = context.driver.session()
try { try {
await session.run(cypherDeletePreviousRelations, { await session.writeTransaction(txc => {
postId, return txc.run(
}) `
await session.run(cypherCreateNewTagsAndRelations, { MATCH (post:Post { id: $postId})
postId, OPTIONAL MATCH (post)-[previousRelations:TAGGED]->(tag:Tag)
hashtags, 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 { } finally {
session.close() session.close()

View File

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

View File

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

View File

@ -1,164 +1,121 @@
import extractMentionedUsers from './mentions/extractMentionedUsers' import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
const postAuthorOfComment = async (comment, { context }) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const cypherFindUser = ` const idsOfUsers = extractMentionedUsers(args.content)
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) const post = await resolve(root, args, context, resolveInfo)
RETURN user { .id } 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() const session = context.driver.session()
let result let postAuthorId
try { try {
result = await session.run(cypherFindUser, { postAuthorId = await session.readTransaction(transaction => {
commentId: comment.id, 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 { } finally {
session.close() session.close()
} }
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
return postAuthor
} }
const notifyUsers = async (label, id, idsOfUsers, reason, context) => { const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return await validateNotifyUsers(label, reason)
let mentionedCypher
// 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
switch (reason) { switch (reason) {
case 'mentioned_in_post': { case 'mentioned_in_post': {
cypher = ` mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) 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 break
} }
case 'mentioned_in_comment': { case 'mentioned_in_comment': {
cypher = ` mentionedCypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor) AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(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())
`
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())
` `
break 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() const session = context.driver.session()
try { try {
await session.run(cypher, { await session.writeTransaction(transaction => {
id, return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
idsOfUsers,
reason,
}) })
} finally { } finally {
session.close() session.close()
} }
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => {
const idsOfUsers = extractMentionedUsers(args.content) await validateNotifyUsers(label, reason)
const session = context.driver.session()
const post = await resolve(root, args, context, resolveInfo) try {
await session.writeTransaction(async transaction => {
if (post) { await transaction.run(
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context) `
} MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
return post MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
} SET notification.read = FALSE
SET (
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { CASE
let idsOfUsers = extractMentionedUsers(args.content) WHEN notification.createdAt IS NULL
const comment = await resolve(root, args, context, resolveInfo) THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
if (comment) { `,
const postAuthor = await postAuthorOfComment(comment, { context }) { commentId, postAuthorId, reason },
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')
}) })
if (context.user.id !== postAuthor.id) { } finally {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context) session.close()
}
} }
return comment
} }
export default { export default {
Mutation: { Mutation: {
CreatePost: handleContentDataOfPost, CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost, UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment, CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment, UpdateComment: handleContentDataOfComment,
}, },
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE = const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three' '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 validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -14,14 +14,15 @@ const validateCreateComment = async (resolve, root, args, context, info) => {
} }
const session = context.driver.session() const session = context.driver.session()
try { try {
const postQueryRes = await session.run( const postQueryRes = await session.readTransaction(transaction => {
` return transaction.run(
MATCH (post:Post {id: $postId}) `
RETURN post`, MATCH (post:Post {id: $postId})
{ RETURN post
postId, `,
}, { postId },
) )
})
const [post] = postQueryRes.records.map(record => { const [post] = postQueryRes.records.map(record => {
return record.get('post') return record.get('post')
}) })
@ -72,8 +73,8 @@ const validateReview = async (resolve, root, args, context, info) => {
const { user, driver } = context const { user, driver } = context
if (resourceId === user.id) throw new Error('You cannot review yourself!') if (resourceId === user.id) throw new Error('You cannot review yourself!')
const session = driver.session() const session = driver.session()
const reportReadTxPromise = session.writeTransaction(async txc => { const reportReadTxPromise = session.readTransaction(async transaction => {
const validateReviewTransactionResponse = await txc.run( const validateReviewTransactionResponse = await transaction.run(
` `
MATCH (resource {id: $resourceId}) MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Post OR resource:Comment 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) 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 { export default {
Mutation: { Mutation: {
CreateComment: validateCreateComment, CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment, UpdateComment: validateUpdateComment,
CreatePost: validatePost, CreatePost: validatePost,
UpdatePost: validateUpdatePost, UpdatePost: validateUpdatePost,
UpdateUser: validateUpdateUser,
fileReport: validateReport, fileReport: validateReport,
review: validateReview, review: validateReview,
}, },

View File

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

View File

@ -5,6 +5,7 @@ export default {
Mutation: { Mutation: {
CreateComment: async (object, params, context, resolveInfo) => { CreateComment: async (object, params, context, resolveInfo) => {
const { postId } = params const { postId } = params
const { user, driver } = context
// Adding relationship from comment to post by passing in the postId, // 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 // 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 // because we use relationships for this. So, we are deleting it from params
@ -12,26 +13,28 @@ export default {
delete params.postId delete params.postId
params.id = params.id || uuid() 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 { try {
const createCommentCypher = ` const [comment] = await writeTxResultPromise
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)
return comment return comment
} finally { } finally {
session.close() session.close()
@ -39,15 +42,22 @@ export default {
}, },
UpdateComment: async (_parent, params, context, _resolveInfo) => { UpdateComment: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session() 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 { try {
const updateCommentCypher = ` const [comment] = await writeTxResultPromise
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)
return comment return comment
} finally { } finally {
session.close() session.close()
@ -55,18 +65,23 @@ export default {
}, },
DeleteComment: async (_parent, args, context, _resolveInfo) => { DeleteComment: async (_parent, args, context, _resolveInfo) => {
const session = context.driver.session() const session = context.driver.session()
try { const writeTxResultPromise = session.writeTransaction(async transaction => {
const transactionRes = await session.run( const deleteCommentTransactionResponse = await transaction.run(
` `
MATCH (comment:Comment {id: $commentId}) MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE SET comment.deleted = TRUE
SET comment.content = 'UNAVAILABLE' SET comment.content = 'UNAVAILABLE'
SET comment.contentExcerpt = 'UNAVAILABLE' SET comment.contentExcerpt = 'UNAVAILABLE'
RETURN comment RETURN comment
`, `,
{ commentId: args.id }, { 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 return comment
} finally { } finally {
session.close() session.close()

View File

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

View File

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

View File

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

View File

@ -1,25 +1,29 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail({ args, context }) { 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() const session = context.driver.session()
try { 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 { } finally {
session.close() session.close()
} }
const [result] = transactionRes.records.map(record => {
return {
alreadyExistingEmail: record.get('email').properties,
user: record.get('user') && record.get('user').properties,
}
})
const { alreadyExistingEmail, user } = result || {}
if (user) throw new UserInputError('A user account with this email already exists.')
return alreadyExistingEmail
} }

View File

@ -76,16 +76,21 @@ export default {
markAsRead: async (parent, args, context, resolveInfo) => { markAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context const { user: currentUser } = context
const session = context.driver.session() 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 { try {
const cypher = ` const [notifications] = await writeTxResultPromise
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) return notifications
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]
} finally { } finally {
session.close() session.close()
} }

View File

@ -12,25 +12,29 @@ export default {
const stillValid = new Date() const stillValid = new Date()
stillValid.setDate(stillValid.getDate() - 1) stillValid.setDate(stillValid.getDate() - 1)
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) 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() const session = driver.session()
try { try {
const transactionRes = await session.run(cypher, { const passwordResetTxPromise = session.writeTransaction(async transaction => {
stillValid, const passwordResetTransactionResponse = await transaction.run(
email, `
nonce, MATCH (passwordReset:PasswordReset {nonce: $nonce})
encryptedNewPassword, 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 [reset] = await passwordResetTxPromise
const response = !!(reset && reset.properties.usedAt) return !!(reset && reset.properties.usedAt)
return response
} finally { } finally {
session.close() session.close()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server' import { AuthenticationError } from 'apollo-server'
import { getNeode } from '../../bootstrap/neo4j' import { getNeode } from '../../bootstrap/neo4j'
import normalizeEmail from './helpers/normalizeEmail' import normalizeEmail from './helpers/normalizeEmail'
import log from './helpers/databaseLogger'
const neode = getNeode() const neode = getNeode()
@ -25,17 +26,18 @@ export default {
email = normalizeEmail(email) email = normalizeEmail(email)
const session = driver.session() const session = driver.session()
try { try {
const result = await session.run( 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 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 }, `,
) { userEmail: email },
const [currentUser] = await result.records.map(record => { )
return record.get('user') log(loginTransactionResponse)
return loginTransactionResponse.records.map(record => record.get('user'))
}) })
const [currentUser] = await loginReadTxResultPromise
if ( if (
currentUser && currentUser &&
(await bcrypt.compareSync(password, currentUser.encryptedPassword)) && (await bcrypt.compareSync(password, currentUser.encryptedPassword)) &&

View File

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

View File

@ -68,6 +68,7 @@ describe('User', () => {
it('is permitted', async () => { it('is permitted', async () => {
await expect(query({ query: userQuery, variables })).resolves.toMatchObject({ await expect(query({ query: userQuery, variables })).resolves.toMatchObject({
data: { User: [{ name: 'Johnny' }] }, data: { User: [{ name: 'Johnny' }] },
errors: undefined,
}) })
}) })
@ -90,8 +91,7 @@ describe('User', () => {
}) })
describe('UpdateUser', () => { describe('UpdateUser', () => {
let userParams let userParams, variables
let variables
beforeEach(async () => { beforeEach(async () => {
userParams = { userParams = {
@ -111,16 +111,23 @@ describe('UpdateUser', () => {
}) })
const updateUserMutation = gql` const updateUserMutation = gql`
mutation($id: ID!, $name: String, $termsAndConditionsAgreedVersion: String) { mutation(
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String
) {
UpdateUser( UpdateUser(
id: $id id: $id
name: $name name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) { ) {
id id
name name
termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt termsAndConditionsAgreedAt
locationName
} }
} }
` `
@ -152,7 +159,7 @@ describe('UpdateUser', () => {
authenticatedUser = await user.toJson() authenticatedUser = await user.toJson()
}) })
it('name within specifications', async () => { it('updates the name', async () => {
const expected = { const expected = {
data: { data: {
UpdateUser: { UpdateUser: {
@ -160,36 +167,13 @@ describe('UpdateUser', () => {
name: 'John Doughnut', name: 'John Doughnut',
}, },
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected, 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', () => { describe('given a new agreed version of terms and conditions', () => {
beforeEach(async () => { beforeEach(async () => {
variables = { ...variables, termsAndConditionsAgreedVersion: '0.0.2' } variables = { ...variables, termsAndConditionsAgreedVersion: '0.0.2' }
@ -202,6 +186,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: expect.any(String), termsAndConditionsAgreedAt: expect.any(String),
}), }),
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -222,6 +207,7 @@ describe('UpdateUser', () => {
termsAndConditionsAgreedAt: null, termsAndConditionsAgreedAt: null,
}), }),
}, },
errors: undefined,
} }
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject( await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
@ -238,6 +224,14 @@ describe('UpdateUser', () => {
const { errors } = await mutate({ mutation: updateUserMutation, variables }) const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors[0]).toHaveProperty('message', 'Invalid version format!') 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( await expect(mutate({ mutation: deleteUserMutation, variables })).resolves.toMatchObject(
expectedResponse, expectedResponse,
@ -418,6 +413,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
@ -465,6 +461,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),
@ -511,6 +508,7 @@ describe('DeleteUser', () => {
], ],
}, },
}, },
errors: undefined,
} }
await expect( await expect(
mutate({ mutation: deleteUserMutation, variables }), mutate({ mutation: deleteUserMutation, variables }),

View File

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

View File

@ -1034,10 +1034,10 @@
url-regex "~4.1.1" url-regex "~4.1.1"
video-extensions "~1.1.0" video-extensions "~1.1.0"
"@metascraper/helpers@^5.8.7": "@metascraper/helpers@^5.8.10", "@metascraper/helpers@^5.8.7":
version "5.8.7" version "5.8.10"
resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.8.7.tgz#b05f83f2a90001f7753c18a8b1bb978bd7c2f9d9" resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.8.10.tgz#efaae1d57afca6db1f0846852fe88d1608601f13"
integrity sha512-gDErMAA3d1CdkGxvAG4cDi7D2+fReZpD6lzYNJ/gsq45U3Pdz7ltsAvbp4amK92bGKYYPZtnUq85Wrr+Q+e06Q== integrity sha512-o7vrlNC+wzfArTkQcQfHKT4iHUYEQYs6hoORTWN7A1dj5v8P1wl5oOs0oAc7MNGJ3nWnex3/bq/5SUWV301Arg==
dependencies: dependencies:
audio-extensions "0.0.0" audio-extensions "0.0.0"
chrono-node "~1.3.11" chrono-node "~1.3.11"
@ -1625,26 +1625,26 @@ apollo-cache-inmemory@~1.6.3:
ts-invariant "^0.4.0" ts-invariant "^0.4.0"
tslib "^1.9.3" tslib "^1.9.3"
apollo-cache@1.3.2, apollo-cache@^1.3.2: apollo-cache@1.3.4, apollo-cache@^1.3.2:
version "1.3.2" version "1.3.4"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a" resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.4.tgz#0c9f63c793e1cd6e34c450f7668e77aff58c9a42"
integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg== integrity sha512-7X5aGbqaOWYG+SSkCzJNHTz2ZKDcyRwtmvW4mGVLRqdQs+HxfXS4dUS2CcwrAj449se6tZ6NLUMnjko4KMt3KA==
dependencies: dependencies:
apollo-utilities "^1.3.2" apollo-utilities "^1.3.3"
tslib "^1.9.3" tslib "^1.10.0"
apollo-client@~2.6.4: apollo-client@~2.6.8:
version "2.6.4" version "2.6.8"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.4.tgz#872c32927263a0d34655c5ef8a8949fbb20b6140" resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.8.tgz#01cebc18692abf90c6b3806414e081696b0fa537"
integrity sha512-oWOwEOxQ9neHHVZrQhHDbI6bIibp9SHgxaLRVPoGvOFy7OH5XUykZE7hBQAVxq99tQjBzgytaZffQkeWo1B4VQ== integrity sha512-0zvJtAcONiozpa5z5zgou83iEKkBaXhhSSXJebFHRXs100SecDojyUWKjwTtBPn9HbM6o5xrvC5mo9VQ5fgAjw==
dependencies: dependencies:
"@types/zen-observable" "^0.8.0" "@types/zen-observable" "^0.8.0"
apollo-cache "1.3.2" apollo-cache "1.3.4"
apollo-link "^1.0.0" apollo-link "^1.0.0"
apollo-utilities "1.3.2" apollo-utilities "1.3.3"
symbol-observable "^1.0.2" symbol-observable "^1.0.2"
ts-invariant "^0.4.0" ts-invariant "^0.4.0"
tslib "^1.9.3" tslib "^1.10.0"
zen-observable "^0.8.0" zen-observable "^0.8.0"
apollo-datasource@^0.6.3: apollo-datasource@^0.6.3:
@ -1847,15 +1847,15 @@ apollo-tracing@^0.8.8:
apollo-server-env "^2.4.3" apollo-server-env "^2.4.3"
graphql-extensions "^0.10.7" graphql-extensions "^0.10.7"
apollo-utilities@1.3.2, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2: apollo-utilities@1.3.3, apollo-utilities@^1.0.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2, apollo-utilities@^1.3.3:
version "1.3.2" version "1.3.3"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.3.tgz#f1854715a7be80cd810bc3ac95df085815c0787c"
integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== integrity sha512-F14aX2R/fKNYMvhuP2t9GD9fggID7zp5I96MF5QeKYWDWTrkRdHRp4+SVfXUVN+cXOaB/IebfvRtzPf25CM0zw==
dependencies: dependencies:
"@wry/equality" "^0.1.2" "@wry/equality" "^0.1.2"
fast-json-stable-stringify "^2.0.0" fast-json-stable-stringify "^2.0.0"
ts-invariant "^0.4.0" ts-invariant "^0.4.0"
tslib "^1.9.3" tslib "^1.10.0"
aproba@^1.0.3: aproba@^1.0.3:
version "1.2.0" version "1.2.0"
@ -3412,10 +3412,10 @@ eslint-plugin-node@~10.0.0:
resolve "^1.10.1" resolve "^1.10.1"
semver "^6.1.0" semver "^6.1.0"
eslint-plugin-prettier@~3.1.1: eslint-plugin-prettier@~3.1.2:
version "3.1.1" version "3.1.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.1.tgz#507b8562410d02a03f0ddc949c616f877852f2ba" resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz#432e5a667666ab84ce72f945c72f77d996a5c9ba"
integrity sha512-A+TZuHZ0KU0cnn56/9mfR7/KjUJ9QNVXUhwvRFSR7PGPe0zQR6PTkmyqg1AtUUEOzTqeRsUwyKFh0oVZKVCrtA== integrity sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==
dependencies: dependencies:
prettier-linter-helpers "^1.0.0" prettier-linter-helpers "^1.0.0"
@ -5810,12 +5810,12 @@ merge-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
metascraper-audio@^5.8.7: metascraper-audio@^5.8.10:
version "5.8.7" version "5.8.10"
resolved "https://registry.yarnpkg.com/metascraper-audio/-/metascraper-audio-5.8.7.tgz#ce27b1f4056c1d1cbaa2cec0e819c3704f38fff4" resolved "https://registry.yarnpkg.com/metascraper-audio/-/metascraper-audio-5.8.10.tgz#bc7bc0471ee178ab747baec4fb9bf7443078980d"
integrity sha512-ew9KZKOIl3u0500j7qIR/ZNiVtSohuyyiIWSxJVEeeguEOwAhMpOrpYAEkvKRo5CB89F2PNBIsXJIzMC4BWFrw== integrity sha512-uR4PCG7mxz7GLZ3I3x83sTCAaD/+MMTSf5rtP+shfdGJCm6h3mNmUpZm6hlBunmBx/PpDpwdI34rkl2A8SUjnQ==
dependencies: dependencies:
"@metascraper/helpers" "^5.8.7" "@metascraper/helpers" "^5.8.10"
metascraper-author@^5.8.7: metascraper-author@^5.8.7:
version "5.8.7" version "5.8.7"
@ -5839,12 +5839,12 @@ metascraper-date@^5.8.7:
dependencies: dependencies:
"@metascraper/helpers" "^5.8.7" "@metascraper/helpers" "^5.8.7"
metascraper-description@^5.8.7: metascraper-description@^5.8.10:
version "5.8.7" version "5.8.10"
resolved "https://registry.yarnpkg.com/metascraper-description/-/metascraper-description-5.8.7.tgz#e85ce218daf33b74813b1523ad7dc7dc3fb128af" resolved "https://registry.yarnpkg.com/metascraper-description/-/metascraper-description-5.8.10.tgz#1b69f59fa76263fcd2c15f8ce73052b81900177a"
integrity sha512-KOv5gnQVvGF1CgpUczu7KJm76rWJ7SH5UFcqFST60hRNgR9xy0y3aHbVDOhZkjNN4UKqnxMF6XTS/WaQxCK/AA== integrity sha512-0stYkl5OPpM0yM6Dl3WcXxLjl2gY5k77E4seeHOqHAUx1EKXNgrSrtO0I3PX9p6vcxP+WBtK6zlqHYU4qAMlSA==
dependencies: dependencies:
"@metascraper/helpers" "^5.8.7" "@metascraper/helpers" "^5.8.10"
metascraper-image@^5.8.7: metascraper-image@^5.8.7:
version "5.8.7" version "5.8.7"
@ -5869,12 +5869,12 @@ metascraper-lang@^5.8.9:
dependencies: dependencies:
"@metascraper/helpers" "^5.8.7" "@metascraper/helpers" "^5.8.7"
metascraper-logo@^5.8.7: metascraper-logo@^5.8.10:
version "5.8.7" version "5.8.10"
resolved "https://registry.yarnpkg.com/metascraper-logo/-/metascraper-logo-5.8.7.tgz#5efb7e6c5f91ccad812e2d9ec3facfef179f40b6" resolved "https://registry.yarnpkg.com/metascraper-logo/-/metascraper-logo-5.8.10.tgz#8e0dc0296d71db03307584ecdb57cd3fcbad1d4b"
integrity sha512-QudGVJBBeXLWU54Xw2PmnsTf+qPUnbyYaOl4aFLg2wkLLza1GbuvOYGMiH9Y8k0WcRoesi9sQk+P0a/611blew== integrity sha512-l5LkzZcVzrKclzf3JGx2cnCtPI/8Rf+EQV/SfXUqz7FUwgfT3uzRw9wBbqP25056ukh6aOuywGClTdnEu2PJcw==
dependencies: dependencies:
"@metascraper/helpers" "^5.8.7" "@metascraper/helpers" "^5.8.10"
metascraper-publisher@^5.8.7: metascraper-publisher@^5.8.7:
version "5.8.7" version "5.8.7"
@ -6161,10 +6161,10 @@ neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.6:
text-encoding-utf-8 "^1.0.2" text-encoding-utf-8 "^1.0.2"
uri-js "^4.2.2" uri-js "^4.2.2"
neo4j-graphql-js@^2.10.0: neo4j-graphql-js@^2.10.1:
version "2.10.0" version "2.10.1"
resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.10.0.tgz#4298793756d839dedb98bc3e50a2bd40a311874d" resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.10.1.tgz#e470d067db681bac8f4daa755f697000110aca4b"
integrity sha512-jRdIyw+DHg9gfB6pWKb1ZHMR9rXIl7qf51efjUHIRHRbVR3RCcw1cKyONkq4LE8v2bHc7QDrKwJs+GQ1SRxDug== integrity sha512-D6Gimu39lkg+3pXKWR3qEY6yMXOv/JOdKSizsYSAE73lj9CubJAYx4hdtmNXJ0Tyy+C9LxcPZwWZEzg0P9niEw==
dependencies: dependencies:
"@babel/runtime" "^7.5.5" "@babel/runtime" "^7.5.5"
"@babel/runtime-corejs2" "^7.5.5" "@babel/runtime-corejs2" "^7.5.5"
@ -6270,15 +6270,15 @@ nodemailer-html-to-text@^3.1.0:
dependencies: dependencies:
html-to-text "^5.1.1" html-to-text "^5.1.1"
nodemailer@^6.4.1: nodemailer@^6.4.2:
version "6.4.1" version "6.4.2"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.1.tgz#f70b40355b7b08f1f80344b353970a4f8f664370" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.2.tgz#7147550e32cdc37453380ab78d2074533966090a"
integrity sha512-mSQAzMim8XIC1DemK9TifDTIgASfoJEllG5aC1mEtZeZ+FQyrSOdGBRth6JRA1ERzHQCET3QHVSd9Kc6mh356g== integrity sha512-g0n4nH1ONGvqYo1v72uSWvF/MRNnnq1LzmSzXb/6EPF3LFb51akOhgG3K2+aETAsJx90/Q5eFNTntu4vBCwyQQ==
nodemon@~2.0.1: nodemon@~2.0.2:
version "2.0.1" version "2.0.2"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.1.tgz#cec436f8153ad5d3e6c27c304849a06cabea71cc" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.2.tgz#9c7efeaaf9b8259295a97e5d4585ba8f0cbe50b0"
integrity sha512-UC6FVhNLXjbbV4UzaXA3wUdbEkUZzLGgMGzmxvWAex5nzib/jhcSHVFlQODdbuUHq8SnnZ4/EABBAbC3RplvPg== integrity sha512-GWhYPMfde2+M0FsHnggIHXTqPDHXia32HRhh6H0d75Mt9FKUoCBvumNHr7LdrpPBTKxsWmIEOjoN+P4IU6Hcaw==
dependencies: dependencies:
chokidar "^3.2.2" chokidar "^3.2.2"
debug "^3.2.6" debug "^3.2.6"
@ -8245,7 +8245,7 @@ ts-invariant@^0.4.0:
dependencies: dependencies:
tslib "^1.9.3" 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" version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==

814
locale/ru.json Normal file
View File

@ -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": "На адрес <b>{email}<\/b>было отправлено электронное письмо с дальнейшими инструкциями"
},
"title": "Сбросить пароль"
}
},
"registration": {
"create-user-account": {
"error": "Не удалось создать учетную запись!",
"help": "Может быть, подтверждение было недействительным? В случае возникновения проблем, не стесняйтесь обращаться за помощью, отправив нам письмо по электронной почте:",
"success": "Учетная запись успешно создана!",
"title": "Создать учетную запись"
},
"signup": {
"form": {
"data-privacy": "Я прочитал и понял <a href=\"https:\/\/human-connection.org\/datenschutz\/\" target=\"_blank\"><ds-text bold color=\"primary\" >Заявление о конфиденциальности<\/ds-text><\/a>",
"description": "Для начала работы введите свой адрес электронной почты:",
"errors": {
"email-exists": "Уже есть учетная запись пользователя с этим адресом электронной почты!",
"invalid-invitation-token": "Похоже, что приглашение уже было использовано. Ссылку из приглашения можно использовать только один раз."
},
"invitation-code": "Код приглашения: <b>{code}<\/b>",
"minimum-age": "Мне 18 лет или более",
"no-commercial": "У меня нет коммерческих намерений, и я не представляю коммерческое предприятие или организацию.",
"no-political": "Я не от имени какой-либо партии или политической организации в сети.",
"submit": "Создать учетную запись",
"success": "Письмо со ссылкой для завершения регистрации было отправлено на <b> {email} <\/b>",
"terms-and-condition": "Принимаю <a href=\"\/terms-and-conditions\"><ds-text bold color=\"primary\" >Условия и положения<\/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": "Ты уверены, что хочешь удалить комментарий \"<b>{name}<\/b>\"?",
"success": "Комментарий успешно удален!",
"title": "Удалить комментарий",
"type": "Комментарий"
},
"contribution": {
"message": "Вы уверены, что хотите удалить пост \"<b>{name}<\/b>\"?",
"success": "Пост успешно удален!",
"title": "Удалить пост",
"type": "Пост"
},
"submit": "Удалить"
},
"disable": {
"cancel": "Отменить",
"comment": {
"message": "Вы действительно хотите отключить комментарий от «<b>{name}<\/b>»?",
"title": "Отключить комментарий",
"type": "Комментарий"
},
"contribution": {
"message": "Вы действительно хотите отключить пост «<b>{name}<\/b>»?",
"title": "Отключить пост",
"type": "Пост"
},
"submit": "Отключить",
"success": "Успешно отключен",
"user": {
"message": "Вы действительно хотите отключить пользователя «<b>{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": "Ты действительно хочешь, чтобы комментарий \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?",
"title": "Окончательно отключить комментарий"
},
"enable": {
"message": "Ты действительно хочешь, чтобы комментарий \"<b>{name}<\/b>\" остановиться и <b>включен<\/b>?",
"title": "Окончательно включить комментарий"
}
},
"Post": {
"disable": {
"message": "Ты действительно хочешь, чтобы пост \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?",
"title": "Окончательно отключить пост"
},
"enable": {
"message": "Ты действительно хочешь, чтобы пост \"<b>{name}<\/b>\" остановиться и <b>включен<\/b>?",
"title": "Окончательно включить пост"
}
},
"submit": "Подтвердить решение",
"User": {
"disable": {
"message": "Ты действительно хочешь, чтобы пользователь \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?",
"title": "Окончательно отключить пользователя"
},
"enable": {
"message": "Ты уверены, что хочешь поделиться пользователем \"<b>{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": "Вы уверены, что хотите показать комментарий \"<b>{name}<\/b>\"?",
"title": "Показать комментарий",
"type": "Комментарий"
},
"contribution": {
"error": "Вы уже сообщили о посте!",
"message": "Вы уверены, что хотите показать пост \"<b>{name}<\/b>\"?",
"title": "Показать пост",
"type": "Пост"
},
"submit": "Показать",
"success": "Успешно показан!",
"user": {
"error": "Вы уже сообщили о пользователе!",
"message": "Вы уверены, что хотите показать пользователя \"<b>{name}<\/b>\"?",
"title": "Показать пользователя",
"type": "Пользователь"
}
},
"report": {
"cancel": "Отменить",
"comment": {
"error": "Вы уже сообщили о посте!",
"message": "Вы действительно хотите сообщить о посте \"<b> {name} <\/b>\"?",
"title": "Пожаловаться на комментарий",
"type": "Комментарий"
},
"contribution": {
"error": "Вы уже сообщили о посте!",
"message": "Вы действительно хотите сообщить о посте \"<b>{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": "Вы действительно хотите сообщить о пользователе \"<b>{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>НЕ СМОЖЕТЕ<\/b> восстановить свой аккаунт, посты или комментарии после удаления.",
"commentedCount": "Удалить мои комментарии: {count}",
"contributionsCount": "Удалить мои посты: {count}",
"name": "Удалить данные",
"pleaseConfirm": "<b class='is-danger'>Разрушительное действие!<\/b> Введите <b>{confirm}<\/b> для подтверждения.",
"success": "Аккаунт успешно удален!"
},
"download": {
"name": "Скачать данные"
},
"email": {
"change-successful": "Адрес электронной почты был успешно изменен.",
"labelEmail": "Адрес электронной почты",
"labelNewEmail": "Новый адрес электронной почты",
"labelNonce": "Введите свой код",
"name": "Электронная почта",
"submitted": "Электронное письмо с подтверждением отправлено на <b>{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": "Cсылка уже существует",
"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": "Сделано с &#10084;",
"register": "Регистрационный номер",
"responsible": "ответственный за содержание этой страницы (§ 55 Abs. 2 RStV)",
"taxident": "UST-ID. в соответствии с §27a Закона о налоге с продаж Германии:",
"termsAndConditions": "Условия и положения",
"thanks": "Спасибо!",
"tribunal": "Суд регистрации"
},
"store": {
"posts": {
"orderBy": {
"newest": {
"label": "Сначала новые"
},
"oldest": {
"label": "Сначала старые"
}
}
}
},
"termsAndConditions": {
"addition": {
"description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>",
"title": "Кроме того, мы регулярно проводим мероприятия, где вы также можете\\nподелиться своими впечатлениями и задать вопросы. Информацию о текущих событиях можно найти здесь:"
},
"agree": "Я согласен(на)!",
"code-of-conduct": {
"description": "Наш кодекс поведения служит руководством для личного поведения и взаимодействия друг с другом. Каждый пользователь социальной сети Human Connection, который пишет статьи, комментирует или вступает в контакт с другими пользователями, даже за пределами сети, признает эти правила поведения обязательными. <a href=\"https:\/\/alpha.human-connection.org\/code-of-conduct\" target=\"_blank\"> https:\/\/alpha.human-connection.org\/code-of-conduct<\/a>",
"title": "Кодекс поведения"
},
"errors-and-feedback": {
"description": "Мы прилагаем все усилия для обеспечения безопасности и доступности нашей сети и данных. Каждый новый выпуск программного обеспечения проходит как автоматическое, так и ручное тестирование. Однако могут возникнуть непредвиденные ошибки. Поэтому мы благодарны за любые обнаруженные ошибки. Вы можете сообщить о любых обнаруженных ошибках, отправив электронное письмо в службу поддержки по адресу support@human-connection.org",
"title": "Ошибки и обратная связь"
},
"help-and-questions": {
"description": "Для справки и вопросов мы собрали для вас исчерпывающую подборку часто задаваемых вопросов и ответов (FAQ). Вы можете найти их здесь: <a href=\"https:\/\/support.human-connection.org\/kb\/\" target=\"_blank\" > https:\/\/support.human-connection.org\/kb\/ <\/a>",
"title": "Помощь и вопросы"
},
"moderation": {
"description": "Пока наши финансовые возможности не позволяют нам реализовать полноценную систему модерации, поэтому мы осуществляем упрощенную модерацию собственными силами и с помощью волонтёров. Мы специально обучаем этих модераторов, поэтому только они принимают соответствующие решения. Модераторы действуют анонимно. Вы можете сообщать нам о постах, комментариях и пользователях (например, если они предоставляют информацию в своем профиле или имеют изображения, которые нарушают настоящие Условия использования). При обращении вы можете указать причину и дать краткое пояснение. Мы рассмотрим обращение и применим санкции в случае необходимости, например, путем блокировки постов, комментариев или пользователей. К сожалению, в настоящее время ни вы ни пострадавший пользователь не получите от нас обратной связи, но мы планируем ряд улучшений в этом направлении. Несмотря на это, мы оставляем за собой право на применение санкций по причинам, которые не могут быть или ещё не указаны в нашем Кодексе поведения или настоящих Условиях использования.",
"title": "Модерация"
},
"newTermsAndConditions": "Новые условия и положения",
"no-commercial-use": {
"description": "Использование Human Connection сети не допускается в коммерческих целях. Это включает, но не ограничивается рекламой продуктов с коммерческими целями, размещением партнерских ссылок, прямым привлечением пожертвований или предоставлением финансовой поддержки для целей, которые не признаются благотворительными для целей налогообложения.",
"title": "Нет коммерческого использования"
},
"privacy-statement": {
"description": "Наша сеть — это социальная сеть знаний и действий. Поэтому для нас особенно важно, чтобы как можно больше контента было общедоступным. В процессе развития нашей сети будет добавлено больше возможностей для управления видимостью личных данных. Об этих новых функциях мы сообщим дополнительно. В противном случае вы должны думать о том, какие личные данные вы раскрываете о себе (или других). Это особенно актуально для содержания постов и комментариев, поскольку они имеют в основном общедоступный характер. Позже появятся возможности ограничения видимости вашего профиля. Часть условий использования — это наша политика конфиденциальности, которая информирует вас об обработке персональных данных в нашей сети: <a href=\"https:\/\/human-connection.org\/datenschutz\/#netzwerk\" target=\"_blank\">https:\/\/human-connection.org\/datenschutz\/#netzwerk<\/a> или <a href=\"https:\/\/human-connection.org\/datenschutz\/\" target=\"_blank\">https:\/\/human-connection.org\/datenschutz<\/a>. Наше заявление о конфиденциальности корректируется в соответствии с законодательством и характеристиками нашей сети и является действительной в настоящей версии.",
"title": "Заявление о конфиденциальности"
},
"terms-of-service": {
"description": "Следующие условия использования являются основой для использования нашей сети. При регистрации вы должны принять их, а мы при необходимости сообщим вам об изменениях. Сеть Human Connection работает в Германии и поэтому регулируется немецким законодательством. Место юрисдикции - Kirchheim \/ Teck. Подробности в выходных данных: <a href=\"https:\/\/human-connection.org\/en\/imprint\" target=\"_blank\">https:\/\/human-connection.org\/en\/imprint<\/a>.",
"title": "Условия обслуживания"
},
"termsAndConditionsConfirmed": "Я прочитал(а) и подтверждаю <a href=\"\/terms-and-conditions\" target=\"_blank\">Условия и положения<\/a>.",
"termsAndConditionsNewConfirm": "Я прочитал(а) и согласен(на) с новыми условиями.",
"termsAndConditionsNewConfirmText": "Пожалуйста, ознакомьтесь с новыми условиями использования!",
"use-and-license": {
"description": "Если размещаемый в сети контент защищен правами на интеллектуальную собственность, вы предоставляете нам неисключительную, передаваемую, сублицензируемую и всемирную лицензию на использование этого контента для публикации в нашей сети. Эта лицензия заканчивается, как только вы удаляете свой контент или учетную запись. Помните, что другие пользователи могут продолжать делиться вашим контентом, и мы не можем его удалить.",
"title": "Использование и лицензия"
}
},
"user": {
"avatar": {
"submitted": "Успешная загрузка!"
}
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "human-connection", "name": "human-connection",
"version": "0.1.12", "version": "0.1.13",
"description": "Fullstack and API tests with cypress and cucumber for Human Connection", "description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh", "author": "Human Connection gGmbh",
"license": "MIT", "license": "MIT",
@ -29,7 +29,7 @@
"codecov": "^3.6.1", "codecov": "^3.6.1",
"cross-env": "^6.0.3", "cross-env": "^6.0.3",
"cucumber": "^6.0.5", "cucumber": "^6.0.5",
"cypress": "^3.7.0", "cypress": "^3.8.0",
"cypress-cucumber-preprocessor": "^1.18.0", "cypress-cucumber-preprocessor": "^1.18.0",
"cypress-file-upload": "^3.5.1", "cypress-file-upload": "^3.5.1",
"cypress-plugin-retries": "^1.5.0", "cypress-plugin-retries": "^1.5.0",

View File

@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ROOT_DIR=$(dirname "$0")/.. ROOT_DIR=$(dirname "$0")/..
DOCKER_CLI_EXPERIMENTAL=enabled
# BUILD_COMMIT=${TRAVIS_COMMIT:-$(git rev-parse HEAD)} # BUILD_COMMIT=${TRAVIS_COMMIT:-$(git rev-parse HEAD)}
IFS='.' read -r major minor patch < $ROOT_DIR/VERSION IFS='.' read -r major minor patch < $ROOT_DIR/VERSION
@ -24,7 +23,7 @@ do
for tag in "${tags[@]}" for tag in "${tags[@]}"
do do
TARGET="humanconnection/${app}:${tag}" TARGET="humanconnection/${app}:${tag}"
if docker manifest inspect $TARGET >/dev/null; then if DOCKER_CLI_EXPERIMENTAL=enabled docker manifest inspect $TARGET >/dev/null; then
echo "docker image ${TARGET} already present, skipping ..." echo "docker image ${TARGET} already present, skipping ..."
else else
echo -e "docker tag $SOURCE $TARGET\ndocker push $TARGET" echo -e "docker tag $SOURCE $TARGET\ndocker push $TARGET"

View File

@ -1,4 +1,4 @@
FROM node:13.1.0-alpine as base FROM node:13.3.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)" 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 EXPOSE 3000

View File

@ -1,4 +1,4 @@
FROM node:13.1.0-alpine as build FROM node:13.3.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)" 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 EXPOSE 3000

View File

@ -22,10 +22,6 @@ describe('ContentMenu.vue', () => {
locale: () => 'en', locale: () => 'en',
}, },
$router: { $router: {
resolve: jest.fn(obj => {
obj.href = '/post/edit/d23a4265-f5f7-4e17-9f86-85f714b4b9f8'
return obj
}),
push: jest.fn(), push: jest.fn(),
}, },
} }
@ -76,7 +72,7 @@ describe('ContentMenu.vue', () => {
.at(0) .at(0)
.find('span.ds-menu-item-link') .find('span.ds-menu-item-link')
.attributes('to'), .attributes('to'),
).toBe('/post/edit/d23a4265-f5f7-4e17-9f86-85f714b4b9f8') ).toBe('/post-edit-id')
}) })
it('can delete the contribution', () => { it('can delete the contribution', () => {

View File

@ -17,7 +17,7 @@
@click.stop.prevent="openItem(item.route, toggleMenu)" @click.stop.prevent="openItem(item.route, toggleMenu)"
> >
<base-icon :name="item.route.icon" /> <base-icon :name="item.route.icon" />
{{ item.route.name }} {{ item.route.label }}
</ds-menu-item> </ds-menu-item>
</ds-menu> </ds-menu>
</div> </div>
@ -58,17 +58,15 @@ export default {
if (this.resourceType === 'contribution') { if (this.resourceType === 'contribution') {
if (this.isOwner) { if (this.isOwner) {
routes.push({ routes.push({
name: this.$t(`post.menu.edit`), label: this.$t(`post.menu.edit`),
path: this.$router.resolve({ name: 'post-edit-id',
name: 'post-edit-id', params: {
params: { id: this.resource.id,
id: this.resource.id, },
},
}).href,
icon: 'edit', icon: 'edit',
}) })
routes.push({ routes.push({
name: this.$t(`post.menu.delete`), label: this.$t(`post.menu.delete`),
callback: () => { callback: () => {
this.openModal('confirm', 'delete') this.openModal('confirm', 'delete')
}, },
@ -79,7 +77,7 @@ export default {
if (this.isAdmin) { if (this.isAdmin) {
if (!this.resource.pinnedBy) { if (!this.resource.pinnedBy) {
routes.push({ routes.push({
name: this.$t(`post.menu.pin`), label: this.$t(`post.menu.pin`),
callback: () => { callback: () => {
this.$emit('pinPost', this.resource) this.$emit('pinPost', this.resource)
}, },
@ -87,7 +85,7 @@ export default {
}) })
} else { } else {
routes.push({ routes.push({
name: this.$t(`post.menu.unpin`), label: this.$t(`post.menu.unpin`),
callback: () => { callback: () => {
this.$emit('unpinPost', this.resource) this.$emit('unpinPost', this.resource)
}, },
@ -99,14 +97,14 @@ export default {
if (this.isOwner && this.resourceType === 'comment') { if (this.isOwner && this.resourceType === 'comment') {
routes.push({ routes.push({
name: this.$t(`comment.menu.edit`), label: this.$t(`comment.menu.edit`),
callback: () => { callback: () => {
this.$emit('showEditCommentMenu', true) this.$emit('showEditCommentMenu', true)
}, },
icon: 'edit', icon: 'edit',
}) })
routes.push({ routes.push({
name: this.$t(`comment.menu.delete`), label: this.$t(`comment.menu.delete`),
callback: () => { callback: () => {
this.openModal('confirm', 'delete') this.openModal('confirm', 'delete')
}, },
@ -116,7 +114,7 @@ export default {
if (!this.isOwner) { if (!this.isOwner) {
routes.push({ routes.push({
name: this.$t(`report.${this.resourceType}.title`), label: this.$t(`report.${this.resourceType}.title`),
callback: () => { callback: () => {
this.openModal('report') this.openModal('report')
}, },
@ -127,7 +125,7 @@ export default {
if (!this.isOwner && this.isModerator) { if (!this.isOwner && this.isModerator) {
if (!this.resource.disabled) { if (!this.resource.disabled) {
routes.push({ routes.push({
name: this.$t(`disable.${this.resourceType}.title`), label: this.$t(`disable.${this.resourceType}.title`),
callback: () => { callback: () => {
this.openModal('disable') this.openModal('disable')
}, },
@ -135,7 +133,7 @@ export default {
}) })
} else { } else {
routes.push({ routes.push({
name: this.$t(`release.${this.resourceType}.title`), label: this.$t(`release.${this.resourceType}.title`),
callback: () => { callback: () => {
this.openModal('release') this.openModal('release')
}, },
@ -147,14 +145,14 @@ export default {
if (this.resourceType === 'user') { if (this.resourceType === 'user') {
if (this.isOwner) { if (this.isOwner) {
routes.push({ routes.push({
name: this.$t(`settings.name`), label: this.$t(`settings.name`),
path: '/settings', path: '/settings',
icon: 'edit', icon: 'edit',
}) })
} else { } else {
if (this.resource.isBlocked) { if (this.resource.isBlocked) {
routes.push({ routes.push({
name: this.$t(`settings.blocked-users.unblock`), label: this.$t(`settings.blocked-users.unblock`),
callback: () => { callback: () => {
this.$emit('unblock', this.resource) this.$emit('unblock', this.resource)
}, },
@ -162,7 +160,7 @@ export default {
}) })
} else { } else {
routes.push({ routes.push({
name: this.$t(`settings.blocked-users.block`), label: this.$t(`settings.blocked-users.block`),
callback: () => { callback: () => {
this.$emit('block', this.resource) this.$emit('block', this.resource)
}, },
@ -186,7 +184,7 @@ export default {
if (route.callback) { if (route.callback) {
route.callback() route.callback()
} else { } else {
this.$router.push(route.path) this.$router.push(route)
} }
toggleMenu() toggleMenu()
}, },

View File

@ -51,23 +51,21 @@
</ds-chip> </ds-chip>
<ds-chip v-else size="base">{{ form.title.length }}/{{ formSchema.title.max }}</ds-chip> <ds-chip v-else size="base">{{ form.title.length }}/{{ formSchema.title.max }}</ds-chip>
</ds-text> </ds-text>
<client-only> <hc-editor
<hc-editor :users="users"
:users="users" :value="form.content"
:value="form.content" :hashtags="hashtags"
:hashtags="hashtags" @input="updateEditorContent"
@input="updateEditorContent" />
/> <ds-text align="right">
<ds-text align="right"> <ds-chip v-if="errors && errors.content" color="danger" size="base">
<ds-chip v-if="errors && errors.content" color="danger" size="base"> {{ contentLength }}
{{ contentLength }} <ds-icon name="warning"></ds-icon>
<ds-icon name="warning"></ds-icon> </ds-chip>
</ds-chip> <ds-chip v-else size="base">
<ds-chip v-else size="base"> {{ contentLength }}
{{ contentLength }} </ds-chip>
</ds-chip> </ds-text>
</ds-text>
</client-only>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
<hc-categories-select model="categoryIds" :existingCategoryIds="form.categoryIds" /> <hc-categories-select model="categoryIds" :existingCategoryIds="form.categoryIds" />
<ds-text align="right"> <ds-text align="right">

View File

@ -24,7 +24,6 @@
import { Editor, EditorContent } from 'tiptap' import { Editor, EditorContent } from 'tiptap'
import { History } from 'tiptap-extensions' import { History } from 'tiptap-extensions'
import linkify from 'linkify-it' import linkify from 'linkify-it'
import stringHash from 'string-hash'
import { replace, build } from 'xregexp/xregexp-all.js' import { replace, build } from 'xregexp/xregexp-all.js'
import * as key from '../../constants/keycodes' import * as key from '../../constants/keycodes'
@ -108,17 +107,6 @@ export default {
}, },
}, },
watch: { 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: { placeholder: {
immediate: true, immediate: true,
handler: function(val) { handler: function(val) {
@ -129,7 +117,7 @@ export default {
}, },
}, },
}, },
created() { mounted() {
this.editor = new Editor({ this.editor = new Editor({
content: this.value || '', content: this.value || '',
doc: this.doc, doc: this.doc,
@ -247,11 +235,7 @@ export default {
}, },
onUpdate(e) { onUpdate(e) {
const content = e.getHTML() const content = e.getHTML()
const contentHash = stringHash(content) this.$emit('input', content)
if (contentHash !== this.lastValueHash) {
this.lastValueHash = contentHash
this.$emit('input', content)
}
}, },
toggleLinkInput(attrs, element) { toggleLinkInput(attrs, element) {
if (!this.isLinkInputActive && attrs && element) { if (!this.isLinkInputActive && attrs && element) {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import unionBy from 'lodash/unionBy'
export default function UpdateQuery(component, { $state, pageKey }) {
if (!pageKey) throw new Error('No key given for the graphql query { data } object')
return (previousResult, { fetchMoreResult }) => {
const oldData = (previousResult && previousResult[pageKey]) || []
const newData = (fetchMoreResult && fetchMoreResult[pageKey]) || []
if (newData.length < component.pageSize) {
component.hasMore = false
$state.complete()
}
const result = {}
result[pageKey] = unionBy(oldData, newData, item => item.id)
$state.loaded()
return result
}
}

View File

@ -0,0 +1,86 @@
import UpdateQuery from './UpdateQuery'
let $state
let component
let pageKey
let updateQuery
let previousResult
let fetchMoreResult
beforeEach(() => {
component = {
hasMore: true,
pageSize: 1,
}
$state = {
complete: jest.fn(),
loaded: jest.fn(),
}
previousResult = { Post: [{ id: 1, foo: 'bar' }] }
fetchMoreResult = { Post: [{ id: 2, foo: 'baz' }] }
updateQuery = () => UpdateQuery(component, { $state, pageKey })
})
describe('UpdateQuery', () => {
it('throws error because no key is given', () => {
expect(() => {
updateQuery()({ Post: [] }, { fetchMoreResult: { Post: [] } })
}).toThrow(/No key given/)
})
describe('with a page key', () => {
beforeEach(() => (pageKey = 'Post'))
describe('given two arrays of things', () => {
it('merges the arrays', () => {
expect(updateQuery()(previousResult, { fetchMoreResult })).toEqual({
Post: [
{ id: 1, foo: 'bar' },
{ id: 2, foo: 'baz' },
],
})
})
it('does not create duplicates', () => {
fetchMoreResult = { Post: [{ id: 1, foo: 'baz' }] }
expect(updateQuery()(previousResult, { fetchMoreResult })).toEqual({
Post: [{ id: 1, foo: 'bar' }],
})
})
it('does not call $state.complete()', () => {
expect(updateQuery()(previousResult, { fetchMoreResult }))
expect($state.complete).not.toHaveBeenCalled()
})
describe('in case of fewer records than pageSize', () => {
beforeEach(() => (component.pageSize = 10))
it('calls $state.complete()', () => {
expect(updateQuery()(previousResult, { fetchMoreResult }))
expect($state.complete).toHaveBeenCalled()
})
it('changes component.hasMore to `false`', () => {
expect(component.hasMore).toBe(true)
expect(updateQuery()(previousResult, { fetchMoreResult }))
expect(component.hasMore).toBe(false)
})
})
})
describe('given one array is undefined', () => {
describe('does not crash', () => {
it('neither if the previous data was undefined', () => {
expect(updateQuery()(undefined, { fetchMoreResult })).toEqual({
Post: [{ id: 2, foo: 'baz' }],
})
})
it('not if the new data is undefined', () => {
expect(updateQuery()(previousResult, {})).toEqual({ Post: [{ id: 1, foo: 'bar' }] })
})
})
})
})
})

View File

@ -1,6 +1,6 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
export const linkableUserFragment = lang => gql` export const userFragment = gql`
fragment user on User { fragment user on User {
id id
slug slug
@ -10,19 +10,8 @@ export const linkableUserFragment = lang => gql`
deleted deleted
} }
` `
export const userFragment = lang => gql` export const locationAndBadgesFragment = lang => gql`
fragment user on User { fragment locationAndBadges on User {
id
slug
name
avatar
disabled
deleted
shoutedCount
contributionsCount
commentedCount
followedByCount
followedByCurrentUser
location { location {
name: name${lang} name: name${lang}
} }
@ -33,15 +22,17 @@ export const userFragment = lang => gql`
} }
` `
export const postCountsFragment = gql` export const userCountsFragment = gql`
fragment postCounts on Post { fragment userCounts on User {
commentsCount
shoutedCount shoutedCount
shoutedByCurrentUser contributionsCount
emotionsCount commentedCount
followedByCount
followedByCurrentUser
} }
` `
export const postFragment = lang => gql`
export const postFragment = gql`
fragment post on Post { fragment post on Post {
id id
title title
@ -58,6 +49,22 @@ export const postFragment = lang => gql`
author { author {
...user ...user
} }
pinnedAt
imageAspectRatio
}
`
export const postCountsFragment = gql`
fragment postCounts on Post {
commentsCount
shoutedCount
shoutedByCurrentUser
emotionsCount
}
`
export const tagsCategoriesAndPinnedFragment = gql`
fragment tagsCategoriesAndPinned on Post {
tags { tags {
id id
} }
@ -72,11 +79,10 @@ export const postFragment = lang => gql`
name name
role role
} }
pinnedAt
imageAspectRatio
} }
` `
export const commentFragment = lang => gql`
export const commentFragment = gql`
fragment comment on Comment { fragment comment on Comment {
id id
createdAt createdAt
@ -85,8 +91,5 @@ export const commentFragment = lang => gql`
deleted deleted
content content
contentExcerpt contentExcerpt
author {
...user
}
} }
` `

View File

@ -1,20 +1,42 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { userFragment, postFragment, commentFragment, postCountsFragment } from './Fragments' import {
userFragment,
postFragment,
commentFragment,
postCountsFragment,
userCountsFragment,
locationAndBadgesFragment,
tagsCategoriesAndPinnedFragment,
} from './Fragments'
export default i18n => { export default i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${postFragment(lang)} ${userCountsFragment}
${locationAndBadgesFragment(lang)}
${postFragment}
${postCountsFragment} ${postCountsFragment}
${commentFragment(lang)} ${tagsCategoriesAndPinnedFragment}
${commentFragment}
query Post($id: ID!) { query Post($id: ID!) {
Post(id: $id) { Post(id: $id) {
...post ...post
...postCounts ...postCounts
...tagsCategoriesAndPinned
author {
...user
...userCounts
...locationAndBadges
}
comments(orderBy: createdAt_asc) { comments(orderBy: createdAt_asc) {
...comment ...comment
author {
...user
...userCounts
...locationAndBadges
}
} }
} }
} }
@ -24,14 +46,23 @@ export default i18n => {
export const filterPosts = i18n => { export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${postFragment(lang)} ${userCountsFragment}
${locationAndBadgesFragment(lang)}
${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment}
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) { query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
...post ...post
...postCounts ...postCounts
...tagsCategoriesAndPinned
author {
...user
...userCounts
...locationAndBadges
}
} }
} }
` `
@ -40,9 +71,12 @@ export const filterPosts = i18n => {
export const profilePagePosts = i18n => { export const profilePagePosts = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${postFragment(lang)} ${userCountsFragment}
${locationAndBadgesFragment(lang)}
${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment}
query profilePagePosts( query profilePagePosts(
$filter: _PostFilter $filter: _PostFilter
@ -53,6 +87,12 @@ export const profilePagePosts = i18n => {
profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) { profilePagePosts(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
...post ...post
...postCounts ...postCounts
...tagsCategoriesAndPinned
author {
...user
...userCounts
...locationAndBadges
}
} }
} }
` `
@ -69,17 +109,32 @@ export const PostsEmotionsByCurrentUser = () => {
export const relatedContributions = i18n => { export const relatedContributions = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${postFragment(lang)} ${userCountsFragment}
${locationAndBadgesFragment(lang)}
${postFragment}
${postCountsFragment} ${postCountsFragment}
${tagsCategoriesAndPinnedFragment}
query Post($slug: String!) { query Post($slug: String!) {
Post(slug: $slug) { Post(slug: $slug) {
...post ...post
...postCounts ...postCounts
...tagsCategoriesAndPinned
author {
...user
...userCounts
...locationAndBadges
}
relatedContributions(first: 2) { relatedContributions(first: 2) {
...post ...post
...postCounts ...postCounts
...tagsCategoriesAndPinned
author {
...user
...userCounts
...locationAndBadges
}
} }
} }
} }

View File

@ -1,27 +1,38 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { linkableUserFragment, userFragment, postFragment, commentFragment } from './Fragments' import {
userCountsFragment,
locationAndBadgesFragment,
userFragment,
postFragment,
commentFragment,
} from './Fragments'
export default i18n => { export default i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${userCountsFragment}
${locationAndBadgesFragment(lang)}
query User($id: ID!) { query User($id: ID!) {
User(id: $id) { User(id: $id) {
...user ...user
...userCounts
...locationAndBadges
about about
locationName locationName
createdAt createdAt
badgesCount
followingCount
following(first: 7) {
...user
}
followedByCount
followedByCurrentUser followedByCurrentUser
isBlocked isBlocked
following(first: 7) {
...user
...userCounts
...locationAndBadges
}
followedBy(first: 7) { followedBy(first: 7) {
...user ...user
...userCounts
...locationAndBadges
} }
socialMedia { socialMedia {
id id
@ -47,11 +58,10 @@ export const minimisedUserQuery = () => {
} }
export const notificationQuery = i18n => { export const notificationQuery = i18n => {
const lang = i18n.locale().toUpperCase()
return gql` return gql`
${linkableUserFragment()} ${userFragment}
${commentFragment(lang)} ${commentFragment}
${postFragment(lang)} ${postFragment}
query($read: Boolean, $orderBy: NotificationOrdering, $first: Int, $offset: Int) { query($read: Boolean, $orderBy: NotificationOrdering, $first: Int, $offset: Int) {
notifications(read: $read, orderBy: $orderBy, first: $first, offset: $offset) { notifications(read: $read, orderBy: $orderBy, first: $first, offset: $offset) {
@ -63,11 +73,20 @@ export const notificationQuery = i18n => {
__typename __typename
... on Post { ... on Post {
...post ...post
author {
...user
}
} }
... on Comment { ... on Comment {
...comment ...comment
author {
...user
}
post { post {
...post ...post
author {
...user
}
} }
} }
} }
@ -77,11 +96,10 @@ export const notificationQuery = i18n => {
} }
export const markAsReadMutation = i18n => { export const markAsReadMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql` return gql`
${linkableUserFragment()} ${userFragment}
${commentFragment(lang)} ${commentFragment}
${postFragment(lang)} ${postFragment}
mutation($id: ID!) { mutation($id: ID!) {
markAsRead(id: $id) { markAsRead(id: $id) {
@ -93,11 +111,17 @@ export const markAsReadMutation = i18n => {
__typename __typename
... on Post { ... on Post {
...post ...post
author {
...user
}
} }
... on Comment { ... on Comment {
...comment ...comment
post { post {
...post ...post
author {
...user
}
} }
} }
} }
@ -107,16 +131,19 @@ export const markAsReadMutation = i18n => {
} }
export const followUserMutation = i18n => { export const followUserMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${userCountsFragment}
mutation($id: ID!) { mutation($id: ID!) {
followUser(id: $id) { followUser(id: $id) {
name ...user
...userCounts
followedByCount followedByCount
followedByCurrentUser followedByCurrentUser
followedBy(first: 7) { followedBy(first: 7) {
...user ...user
...userCounts
} }
} }
} }
@ -124,39 +151,61 @@ export const followUserMutation = i18n => {
} }
export const unfollowUserMutation = i18n => { export const unfollowUserMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql` return gql`
${userFragment(lang)} ${userFragment}
${userCountsFragment}
mutation($id: ID!) { mutation($id: ID!) {
unfollowUser(id: $id) { unfollowUser(id: $id) {
name ...user
...userCounts
followedByCount followedByCount
followedByCurrentUser followedByCurrentUser
followedBy(first: 7) { followedBy(first: 7) {
...user ...user
...userCounts
} }
} }
} }
` `
} }
export const allowEmbedIframesMutation = () => { export const updateUserMutation = () => {
return gql` return gql`
mutation($id: ID!, $allowEmbedIframes: Boolean) { mutation(
UpdateUser(id: $id, allowEmbedIframes: $allowEmbedIframes) { $id: ID!
$slug: String
$name: String
$locationName: String
$about: String
$allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$locale: String
$termsAndConditionsAgreedVersion: String
$avatarUpload: Upload
) {
UpdateUser(
id: $id
slug: $slug
name: $name
locationName: $locationName
about: $about
allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
locale: $locale
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatarUpload: $avatarUpload
) {
id id
slug
name
locationName
about
allowEmbedIframes allowEmbedIframes
}
}
`
}
export const showShoutsPubliclyMutation = () => {
return gql`
mutation($id: ID!, $showShoutsPublicly: Boolean) {
UpdateUser(id: $id, showShoutsPublicly: $showShoutsPublicly) {
id
showShoutsPublicly showShoutsPublicly
locale
termsAndConditionsAgreedVersion
avatar
} }
} }
` `
@ -169,14 +218,3 @@ export const checkSlugAvailableQuery = gql`
} }
} }
` `
export const localeMutation = () => {
return gql`
mutation($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`
}

View File

@ -113,7 +113,7 @@
} }
}, },
"deleteUserAccount": { "deleteUserAccount": {
"name": "Daten löschen", "name": "Benutzerkonto löschen",
"contributionsCount": "Meine {count} Beiträge löschen", "contributionsCount": "Meine {count} Beiträge löschen",
"commentedCount": "Meine {count} Kommentare löschen", "commentedCount": "Meine {count} Kommentare löschen",
"accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.", "accountDescription": "Sei dir bewusst, dass deine Beiträge und Kommentare für unsere Community wichtig sind. Wenn du sie trotzdem löschen möchtest, musst du sie unten markieren.",

View File

@ -271,10 +271,10 @@
"name": "Download Data" "name": "Download Data"
}, },
"deleteUserAccount": { "deleteUserAccount": {
"name": "Delete data", "name": "Delete user account",
"contributionsCount": "Delete my {count} posts", "contributionsCount": "Delete my {count} posts",
"commentedCount": "Delete my {count} comments", "commentedCount": "Delete my {count} comments",
"accountDescription": "Be aware that your Post and Comments are important to our community. If you still choose to delete them, you have to mark them below.", "accountDescription": "Be aware that your Posts and Comments are important to our community. If you still choose to delete them, you have to mark them below.",
"accountWarning": "You <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!", "accountWarning": "You <b>CAN'T MANAGE</b> and <b>CAN'T RECOVER</b> your Account, Posts, or Comments after deleting your account!",
"success": "Account successfully deleted!", "success": "Account successfully deleted!",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm" "pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm"

View File

@ -113,7 +113,7 @@
} }
}, },
"deleteUserAccount": { "deleteUserAccount": {
"name": "Borrar datos", "name": "Eliminar cuenta de usuario",
"contributionsCount": "Eliminar mis {count} contribuciones", "contributionsCount": "Eliminar mis {count} contribuciones",
"commentedCount": "Eliminar mis {count} comentarios", "commentedCount": "Eliminar mis {count} comentarios",
"accountDescription": "Tenga en cuenta que su contribución y sus comentarios son importantes para nuestra comunidad. Si aún decide borrarlos, debe marcarlos a continuación.", "accountDescription": "Tenga en cuenta que su contribución y sus comentarios son importantes para nuestra comunidad. Si aún decide borrarlos, debe marcarlos a continuación.",
@ -321,7 +321,63 @@
"disabledBy": "desactivado por", "disabledBy": "desactivado por",
"reasonCategory": "Categoría", "reasonCategory": "Categoría",
"reasonDescription": "Descripción", "reasonDescription": "Descripción",
"createdAt": "Fecha" "status": "Estado actual",
"content": "Contenido",
"author": "Autor",
"decision": "Decisión",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"decided": "Decidido",
"noDecision": "¡No hay decisión!",
"decideButton": "Confirmar",
"DecisionSuccess": "Decidido con éxito!",
"enabledBy": "Habilitado por",
"previousDecision": "Decisión previa:",
"enabledAt": "Habilitado el",
"disabledAt": "Deshabilitado el",
"numberOfUsers": "{count} usuarios",
"filterLabel": {
"all": "Todos",
"unreviewed": "Sin revisar",
"reviewed": "Revisado",
"closed": "Cerrado"
},
"reportedOn": "Fecha",
"moreDetails": "Ver Detalles",
"decideModal": {
"submit": "Confirmar decisión",
"cancel": "Cancelar",
"User": {
"disable": {
"title": "Finalmente Deshabilitar Usuario",
"message": "¿Realmente quiere que el usuario \"<b>{nombre}<\/b>\" permanezca <b>desactivado<\/b>?"
},
"enable": {
"title": "Finalmente Habilitar Usuario",
"message": "¿Realmente quiere que el usuario \"<b>{nombre}<\/b>\" permanezca <b>habilitado<\/b>?"
}
},
"Post": {
"disable": {
"title": "Finalmente Desactivar Contribución",
"message": "¿Realmente quiere que la entrada \"<b>{nombre}<\/b>\" permanezca <b>desactivada<\/b>?"
},
"enable": {
"title": "Finalmente Habilitar Contribución",
"message": "¿Realmente quiere que la contribución \"<b>{nombre}<\/b>\" permanezca <b>activada<\/b>?"
}
},
"Comment": {
"disable": {
"title": "Desactivar finalmente Comentario",
"message": "¿Realmente quiere que el comentario \"<b>{nombre}<\/b>\" permanezca <b>desactivado<\/b>?"
},
"enable": {
"title": "Finalmente Habilitar Comentario",
"message": "¿Realmente quiere que el comentario \"<b>{nombre}<\/b>\" permanezca <b>habilitado<\/b>?"
}
}
}
} }
}, },
"disable": { "disable": {
@ -501,7 +557,9 @@
"invalid-invitation-token": "Parece que el código de invitación ya ha sido canjeado. Cada código sólo se puede utilizar una vez." "invalid-invitation-token": "Parece que el código de invitación ya ha sido canjeado. Cada código sólo se puede utilizar una vez."
}, },
"submit": "Crear una cuenta", "submit": "Crear una cuenta",
"success": "Se ha enviado un correo electrónico con un enlace de confirmación para el registro a <b>{email}<\/b>." "success": "Se ha enviado un correo electrónico con un enlace de confirmación para el registro a <b>{email}<\/b>.",
"no-commercial": "No tengo intensiones comerciales y no represento una empresa u organización comercial.",
"no-political": "No estoy en la red en nombre de un partido o una organización política."
} }
}, },
"create-user-account": { "create-user-account": {
@ -742,6 +800,10 @@
"addition": { "addition": {
"title": "Además, regularmente celebramos eventos donde también puede dar impresiones y hacer preguntas. Puede encontrar un resumen actualizado aquí:", "title": "Además, regularmente celebramos eventos donde también puede dar impresiones y hacer preguntas. Puede encontrar un resumen actualizado aquí:",
"description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>" "description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>"
},
"no-commercial-use": {
"title": "Sin uso comercial",
"description": "El uso de la red Human Connection no está permitido para fines comerciales. Esto incluye, pero no se limita a, publicitar productos con intención comercial, publicar enlaces de afiliados, solicitar donaciones directamente o brindar apoyo financiero para fines que no se reconocen como caritativos para fines fiscales."
} }
}, },
"donations": { "donations": {
@ -749,4 +811,4 @@
"donate-now": "Donar ahora", "donate-now": "Donar ahora",
"amount-of-total": "{amount} de {total} € recaudados" "amount-of-total": "{amount} de {total} € recaudados"
} }
} }

View File

@ -113,7 +113,7 @@
} }
}, },
"deleteUserAccount": { "deleteUserAccount": {
"name": "Effacer les données", "name": "Supprimer un compte utilisateur",
"contributionsCount": "Supprimer mes {count} postes", "contributionsCount": "Supprimer mes {count} postes",
"commentedCount": "Supprimer mes {count} commentaires", "commentedCount": "Supprimer mes {count} commentaires",
"accountDescription": "Sachez que vos postes et commentaires sont importants pour notre communauté. Si vous voulez quand même les supprimer, vous devez les marquer ci-dessous.", "accountDescription": "Sachez que vos postes et commentaires sont importants pour notre communauté. Si vous voulez quand même les supprimer, vous devez les marquer ci-dessous.",
@ -749,4 +749,4 @@
"donate-now": "Faites un don", "donate-now": "Faites un don",
"amount-of-total": "{amount} de {total} € collectés" "amount-of-total": "{amount} de {total} € collectés"
} }
} }

View File

@ -114,6 +114,8 @@
}, },
"deleteUserAccount": { "deleteUserAccount": {
"name": "Cancellare l'account utente", "name": "Cancellare l'account utente",
"contributionsCount": "Cancellare i miei {count} messaggi",
"commentedCount": "Cancella i miei {count} commenti",
"accountDescription": "Essere consapevoli che i tuoi post e commenti sono importanti per la nostra comunità. Se cancelli il tuo account utente, tutto scomparirà per sempre - e sarebbe un vero peccato!", "accountDescription": "Essere consapevoli che i tuoi post e commenti sono importanti per la nostra comunità. Se cancelli il tuo account utente, tutto scomparirà per sempre - e sarebbe un vero peccato!",
"accountWarning": "Attenzione!Tu <b>Non puoi gestire</b> e <b>Non puoi recuperare il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!", "accountWarning": "Attenzione!Tu <b>Non puoi gestire</b> e <b>Non puoi recuperare il tuo account, i tuoi messaggi o commenti dopo aver cancellato il tuo account!",
"success": "Account eliminato con successo!", "success": "Account eliminato con successo!",
@ -236,10 +238,10 @@
"description": "" "description": ""
}, },
"donations": { "donations": {
"name": "", "name": "Info donazioni",
"goal": "", "goal": "Donazioni mensili necessarie",
"progress": "", "progress": "Donazioni raccolte finora",
"successfulUpdate": "" "successfulUpdate": "Informazioni sulle donazioni aggiornate con successo!"
} }
}, },
"post": { "post": {
@ -743,8 +745,8 @@
} }
}, },
"donations": { "donations": {
"donations-for": "", "donations-for": "Donazioni per",
"donate-now": "", "donate-now": "Dona ora",
"amount-of-total": "" "amount-of-total": "{amount} of {total} € collezionato"
} }
} }

View File

@ -321,7 +321,63 @@
"disabledBy": "отключены", "disabledBy": "отключены",
"reasonCategory": "Категория", "reasonCategory": "Категория",
"reasonDescription": "Описание", "reasonDescription": "Описание",
"createdAt": "Дата" "status": "Текущее состояние",
"content": "Содержа́ние",
"author": "Автор",
"decision": "Решение",
"enabled": "Включен",
"disabled": "Отключен",
"decided": "Решил",
"noDecision": "Нет решения!",
"decideButton": "Подтвердить",
"DecisionSuccess": "Решил успешно!",
"enabledBy": "Включено с",
"previousDecision": "Предыдущее решение:",
"enabledAt": "Включено на",
"disabledAt": "Отключено на",
"numberOfUsers": "{count} пользователи",
"filterLabel": {
"all": "Все",
"unreviewed": "Нерассмотренный",
"reviewed": "Рассмотренный",
"closed": "Закрыто"
},
"reportedOn": "Дата",
"moreDetails": "Посмотреть подробности",
"decideModal": {
"submit": "Подтвердить решение",
"cancel": "Отменить",
"User": {
"disable": {
"title": "Окончательно отключить пользователя",
"message": "Ты действительно хочешь, чтобы пользователь \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?"
},
"enable": {
"title": "Окончательно включить пост",
"message": "Ты уверены, что хочешь поделиться пользователем \"<b>{name}<\/b>\"?"
}
},
"Post": {
"disable": {
"title": "Окончательно отключить пост",
"message": "Ты действительно хочешь, чтобы пост \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?"
},
"enable": {
"title": "Окончательно включить пост",
"message": "Ты действительно хочешь, чтобы пост \"<b>{name}<\/b>\" остановиться и <b>включен<\/b>?"
}
},
"Comment": {
"disable": {
"title": "Окончательно отключить комментарий",
"message": "Ты действительно хочешь, чтобы комментарий \"<b>{name}<\/b>\" остановиться и <b>отключен<\/b>?"
},
"enable": {
"title": "Окончательно включить комментарий",
"message": "Ты действительно хочешь, чтобы комментарий \"<b>{name}<\/b>\" остановиться и <b>включен<\/b>?"
}
}
}
} }
}, },
"disable": { "disable": {
@ -501,7 +557,9 @@
"invalid-invitation-token": "Похоже, что приглашение уже использовалось. Ссылки на приглашения можно использовать только один раз." "invalid-invitation-token": "Похоже, что приглашение уже использовалось. Ссылки на приглашения можно использовать только один раз."
}, },
"submit": "Создать учетную запись", "submit": "Создать учетную запись",
"success": "Письмо со ссылкой для завершения регистрации было отправлено на <b> {email} <\/b>" "success": "Письмо со ссылкой для завершения регистрации было отправлено на <b> {email} <\/b>",
"no-commercial": "У меня нет коммерческих намерений, и я не представляю коммерческое предприятие или организацию.",
"no-political": "Я не от имени какой-либо партии или политической организации в сети."
} }
}, },
"create-user-account": { "create-user-account": {
@ -742,6 +800,10 @@
"addition": { "addition": {
"title": "Кроме того, мы регулярно проводим мероприятия, где ты также можешь\nподелиться своими впечатлениями и задать вопросы. Здесь ты можешь найти текущий обзор:", "title": "Кроме того, мы регулярно проводим мероприятия, где ты также можешь\nподелиться своими впечатлениями и задать вопросы. Здесь ты можешь найти текущий обзор:",
"description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>" "description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>"
},
"no-commercial-use": {
"title": "Нет коммерческого использования",
"description": "Использование Human Connection сети не допускается в коммерческих целях. Это включает, но не ограничивается рекламой продуктов с коммерческими целями, размещением партнерских ссылок, прямым привлечением пожертвований или предоставлением финансовой поддержки для целей, которые не признаются благотворительными для целей налогообложения."
} }
}, },
"donations": { "donations": {

View File

@ -58,7 +58,7 @@
}, },
"dependencies": { "dependencies": {
"@human-connection/styleguide": "0.5.22", "@human-connection/styleguide": "0.5.22",
"@nuxtjs/apollo": "^4.0.0-rc18", "@nuxtjs/apollo": "^4.0.0-rc19",
"@nuxtjs/axios": "~5.8.0", "@nuxtjs/axios": "~5.8.0",
"@nuxtjs/dotenv": "~1.4.1", "@nuxtjs/dotenv": "~1.4.1",
"@nuxtjs/pwa": "^3.0.0-beta.19", "@nuxtjs/pwa": "^3.0.0-beta.19",
@ -80,7 +80,6 @@
"nuxt-dropzone": "^1.0.4", "nuxt-dropzone": "^1.0.4",
"nuxt-env": "~0.1.0", "nuxt-env": "~0.1.0",
"stack-utils": "^1.0.2", "stack-utils": "^1.0.2",
"string-hash": "^1.1.3",
"tippy.js": "^4.3.5", "tippy.js": "^4.3.5",
"tiptap": "~1.26.3", "tiptap": "~1.26.3",
"tiptap-extensions": "~1.28.5", "tiptap-extensions": "~1.28.5",
@ -116,7 +115,7 @@
"babel-plugin-require-context-hook": "^1.0.0", "babel-plugin-require-context-hook": "^1.0.0",
"babel-preset-vue": "~2.0.2", "babel-preset-vue": "~2.0.2",
"core-js": "~2.6.10", "core-js": "~2.6.10",
"css-loader": "~3.3.0", "css-loader": "~3.3.2",
"eslint": "~6.7.2", "eslint": "~6.7.2",
"eslint-config-prettier": "~6.7.0", "eslint-config-prettier": "~6.7.0",
"eslint-config-standard": "~14.1.0", "eslint-config-standard": "~14.1.0",

View File

@ -68,6 +68,7 @@ import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import { filterPosts } from '~/graphql/PostQuery.js' import { filterPosts } from '~/graphql/PostQuery.js'
import PostMutations from '~/graphql/PostMutations' import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
export default { export default {
components: { components: {
@ -151,27 +152,7 @@ export default {
first: this.pageSize, first: this.pageSize,
orderBy: ['pinned_asc', this.orderBy], orderBy: ['pinned_asc', this.orderBy],
}, },
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: UpdateQuery(this, { $state, pageKey: 'Post' }),
if (!fetchMoreResult || fetchMoreResult.Post.length < this.pageSize) {
this.hasMore = false
$state.complete()
}
const { Post = [] } = previousResult
const result = {
...previousResult,
Post: [
...Post.filter(prevPost => {
return (
fetchMoreResult.Post.filter(newPost => newPost.id === prevPost.id).length === 0
)
}),
...fetchMoreResult.Post,
],
}
$state.loaded()
return result
},
}) })
}, },
deletePost(deletedPost) { deletePost(deletedPost) {

View File

@ -283,6 +283,7 @@ import { profilePagePosts } from '~/graphql/PostQuery'
import UserQuery from '~/graphql/User' import UserQuery from '~/graphql/User'
import { Block, Unblock } from '~/graphql/settings/BlockedUsers' import { Block, Unblock } from '~/graphql/settings/BlockedUsers'
import PostMutations from '~/graphql/PostMutations' import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
const tabToFilterMapping = ({ tab, id }) => { const tabToFilterMapping = ({ tab, id }) => {
return { return {
@ -385,27 +386,7 @@ export default {
first: this.pageSize, first: this.pageSize,
orderBy: 'createdAt_desc', orderBy: 'createdAt_desc',
}, },
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: UpdateQuery(this, { $state, pageKey: 'profilePagePosts' }),
if (!fetchMoreResult || fetchMoreResult.profilePagePosts.length < this.pageSize) {
this.hasMore = false
$state.complete()
}
const { profilePagePosts = [] } = previousResult
const result = {
...previousResult,
profilePagePosts: [
...profilePagePosts.filter(prevPost => {
return (
fetchMoreResult.profilePagePosts.filter(newPost => newPost.id === prevPost.id)
.length === 0
)
}),
...fetchMoreResult.profilePagePosts,
],
}
$state.loaded()
return result
},
}) })
}, },
resetPostList() { resetPostList() {

View File

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

View File

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

View File

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

View File

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

View File

@ -1574,14 +1574,14 @@
webpack-node-externals "^1.7.2" webpack-node-externals "^1.7.2"
webpackbar "^4.0.0" webpackbar "^4.0.0"
"@nuxtjs/apollo@^4.0.0-rc18": "@nuxtjs/apollo@^4.0.0-rc19":
version "4.0.0-rc18" version "4.0.0-rc19"
resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc18.tgz#0069cae64f414ed879d20de00881986dca6bb26c" resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc19.tgz#145b50c8e0185dac83c37f48ab685861f9005850"
integrity sha512-DTwRw9XLJKyphZiVwtKn4hE6Vfn6BlxEDWFBMTXpKE3XUKpg5+Qcgr8GstkiKtWbOuNQi660KdZReJ48R8bxgQ== integrity sha512-OCUxdhz09vTA7jq4KrhdYw23PRXS4yHWST99Ohc1oSUiZUyNrmQc+VUNAz9bhSVjfHABrP1NP2FzKnBE1iEZhA==
dependencies: dependencies:
cross-fetch "^3.0.4" cross-fetch "^3.0.4"
universal-cookie "^4.0.2" universal-cookie "^4.0.2"
vue-apollo "^3.0.1" vue-apollo "^3.0.2"
vue-cli-plugin-apollo "^0.21.3" vue-cli-plugin-apollo "^0.21.3"
webpack-node-externals "^1.7.2" webpack-node-externals "^1.7.2"
@ -6176,10 +6176,10 @@ css-has-pseudo@^0.10.0:
postcss "^7.0.6" postcss "^7.0.6"
postcss-selector-parser "^5.0.0-rc.4" postcss-selector-parser "^5.0.0-rc.4"
css-loader@^3.0.0, css-loader@^3.2.0, css-loader@~3.3.0: css-loader@^3.0.0, css-loader@^3.2.0, css-loader@~3.3.2:
version "3.3.0" version "3.3.2"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.3.0.tgz#65f889807baec3197313965d6cda9899f936734d" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.3.2.tgz#41b2086528aa4fbf8c0692e874bc14f081129b21"
integrity sha512-x9Y1vvHe5RR+4tzwFdWExPueK00uqFTCw7mZy+9aE/X1SKWOArm5luaOrtJ4d05IpOwJ6S86b/tVcIdhw1Bu4A== integrity sha512-4XSiURS+YEK2fQhmSaM1onnUm0VKWNf6WWBYjkp9YbSDGCBTVZ5XOM6Gkxo8tLgQlzkZOBJvk9trHlDk4gjEYg==
dependencies: dependencies:
camelcase "^5.3.1" camelcase "^5.3.1"
cssesc "^3.0.0" cssesc "^3.0.0"
@ -14302,11 +14302,6 @@ serve-static@1.14.1, serve-static@^1.14.1:
version "1.14.1" version "1.14.1"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== 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"
server-destroy@^1.0.1: server-destroy@^1.0.1:
version "1.0.1" version "1.0.1"
@ -15052,11 +15047,6 @@ strict-uri-encode@^1.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
string-hash@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=
string-length@^2.0.0: string-length@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"
@ -16283,10 +16273,10 @@ vscode-uri@^1.0.6:
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.8.tgz#9769aaececae4026fb6e22359cb38946580ded59" resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.8.tgz#9769aaececae4026fb6e22359cb38946580ded59"
integrity sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ== integrity sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==
vue-apollo@^3.0.1: vue-apollo@^3.0.2:
version "3.0.1" version "3.0.2"
resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.1.tgz#b7c24b6d6032bf707be7872e6615d59aa6621241" resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.2.tgz#b198ecfa3765850a0b9f2b84ffaa7fbd8ec15f52"
integrity sha512-NM+kWbPGV5bnRMK7BmMJMxoT1NqPjVAYf+MsjPDyfQNgyVEHSIObRVqLQDIs56PYQSC6YOGa0luo6Ykjj6rrPw== integrity sha512-lrKyTT1L5mjDEp7nyqnTRJwD/kTpLDBIqFfZ+TGQVivjlUz6o5VA0pLYGCx5cGa1gEF/ERWc0AEdNSdKgs7Ygg==
dependencies: dependencies:
chalk "^2.4.2" chalk "^2.4.2"
serialize-javascript "^2.1.0" serialize-javascript "^2.1.0"

View File

@ -2240,10 +2240,10 @@ cypress-plugin-retries@^1.5.0:
dependencies: dependencies:
chalk "^3.0.0" chalk "^3.0.0"
cypress@^3.7.0: cypress@^3.8.0:
version "3.7.0" version "3.8.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.7.0.tgz#e2cd71b87b6ce0d4c72c6ea25da1005d75c1f231" resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.8.0.tgz#7d4cd08f81f9048ee36760cc9ee3b9014f9e84ab"
integrity sha512-o+vfRxqAba8TduelzfZQ4WHmj2yNEjaoO2EuZ8dZ9pJpuW+WGtBGheKIp6zkoQsp8ZgFe8OoHh1i2mY8BDnMAw== integrity sha512-gtEbqCgKETRc3pQFMsELRgIBNgiQg7vbOWTrCi7WE7bgOwNCaW9PEX8Jb3UN8z/maIp9WwzoFfeySfelYY7nRA==
dependencies: dependencies:
"@cypress/listr-verbose-renderer" "0.4.1" "@cypress/listr-verbose-renderer" "0.4.1"
"@cypress/xvfb" "1.2.4" "@cypress/xvfb" "1.2.4"