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).
#### [v0.1.13](https://github.com/Human-Connection/Human-Connection/compare/v0.1.12...v0.1.13)
> 13 December 2019
- Update de.json [`#2492`](https://github.com/Human-Connection/Human-Connection/pull/2492)
- Fix broken scroll behaviour on index and profile page [`#2487`](https://github.com/Human-Connection/Human-Connection/pull/2487)
- Lokalise: Translations update [`#2503`](https://github.com/Human-Connection/Human-Connection/pull/2503)
- build(deps): bump node from 13.1.0-alpine to 13.3.0-alpine in /webapp [`#2454`](https://github.com/Human-Connection/Human-Connection/pull/2454)
- Lokalise: Translations update [`#2485`](https://github.com/Human-Connection/Human-Connection/pull/2485)
- build(deps-dev): bump css-loader from 3.3.0 to 3.3.2 in /webapp [`#2505`](https://github.com/Human-Connection/Human-Connection/pull/2505)
- build(deps-dev): bump cypress from 3.7.0 to 3.8.0 [`#2504`](https://github.com/Human-Connection/Human-Connection/pull/2504)
- Favor transaction functions [`#2433`](https://github.com/Human-Connection/Human-Connection/pull/2433)
- build(deps): bump nodemailer from 6.4.1 to 6.4.2 in /backend [`#2500`](https://github.com/Human-Connection/Human-Connection/pull/2500)
- Update en.json [`#2491`](https://github.com/Human-Connection/Human-Connection/pull/2491)
- Update es.json [`#2493`](https://github.com/Human-Connection/Human-Connection/pull/2493)
- Update fr.json [`#2494`](https://github.com/Human-Connection/Human-Connection/pull/2494)
- Update it.json [`#2496`](https://github.com/Human-Connection/Human-Connection/pull/2496)
- build(deps-dev): bump nodemon from 2.0.1 to 2.0.2 in /backend [`#2499`](https://github.com/Human-Connection/Human-Connection/pull/2499)
- build(deps): bump @nuxtjs/apollo from 4.0.0-rc18 to 4.0.0-rc19 in /webapp [`#2498`](https://github.com/Human-Connection/Human-Connection/pull/2498)
- build(deps): bump neo4j-graphql-js from 2.10.0 to 2.10.1 in /backend [`#2497`](https://github.com/Human-Connection/Human-Connection/pull/2497)
- Fix docker manifest on Travis CI [`#2488`](https://github.com/Human-Connection/Human-Connection/pull/2488)
- build(deps-dev): bump @babel/core from 7.7.4 to 7.7.5 [`#2453`](https://github.com/Human-Connection/Human-Connection/pull/2453)
- build(deps-dev): bump cypress-file-upload from 3.5.0 to 3.5.1 [`#2489`](https://github.com/Human-Connection/Human-Connection/pull/2489)
- build(deps): bump cookie-universal-nuxt from 2.0.19 to 2.1.0 in /webapp [`#2490`](https://github.com/Human-Connection/Human-Connection/pull/2490)
- Update to version 0.1.12 [`#2483`](https://github.com/Human-Connection/Human-Connection/pull/2483)
- Lokalise: update of locale/ru.json [`60b3035`](https://github.com/Human-Connection/Human-Connection/commit/60b3035a3d475cb481130c6fe94f2901711a4053)
- Write test/refactor tests/resolvers/middleware [`d375ebe`](https://github.com/Human-Connection/Human-Connection/commit/d375ebe7d90e3251b17f59ffba8fb1470923ebe8)
- Fix this annoying bug with a tested helper [`e24d803`](https://github.com/Human-Connection/Human-Connection/commit/e24d8035b13040dc29f5f9cb033de8c1a401ac34)
#### [v0.1.12](https://github.com/Human-Connection/Human-Connection/compare/v0.1.10...v0.1.12)
> 10 December 2019

View File

@ -1 +1 @@
0.1.12
0.1.13

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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",
"version": "0.1.12",
"version": "0.1.13",
"description": "Fullstack and API tests with cypress and cucumber for Human Connection",
"author": "Human Connection gGmbh",
"license": "MIT",
@ -29,7 +29,7 @@
"codecov": "^3.6.1",
"cross-env": "^6.0.3",
"cucumber": "^6.0.5",
"cypress": "^3.7.0",
"cypress": "^3.8.0",
"cypress-cucumber-preprocessor": "^1.18.0",
"cypress-file-upload": "^3.5.1",
"cypress-plugin-retries": "^1.5.0",

View File

@ -1,6 +1,5 @@
#!/usr/bin/env bash
ROOT_DIR=$(dirname "$0")/..
DOCKER_CLI_EXPERIMENTAL=enabled
# BUILD_COMMIT=${TRAVIS_COMMIT:-$(git rev-parse HEAD)}
IFS='.' read -r major minor patch < $ROOT_DIR/VERSION
@ -24,7 +23,7 @@ do
for tag in "${tags[@]}"
do
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 ..."
else
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)"
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)"
EXPOSE 3000

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -271,10 +271,10 @@
"name": "Download Data"
},
"deleteUserAccount": {
"name": "Delete data",
"name": "Delete user account",
"contributionsCount": "Delete my {count} posts",
"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!",
"success": "Account successfully deleted!",
"pleaseConfirm": "<b class='is-danger'>Destructive action!</b> Type <b>{confirm}</b> to confirm"

View File

@ -113,7 +113,7 @@
}
},
"deleteUserAccount": {
"name": "Borrar datos",
"name": "Eliminar cuenta de usuario",
"contributionsCount": "Eliminar mis {count} contribuciones",
"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.",
@ -321,7 +321,63 @@
"disabledBy": "desactivado por",
"reasonCategory": "Categoría",
"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": {
@ -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."
},
"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": {
@ -742,6 +800,10 @@
"addition": {
"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>"
},
"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": {
@ -749,4 +811,4 @@
"donate-now": "Donar ahora",
"amount-of-total": "{amount} de {total} € recaudados"
}
}
}

View File

@ -113,7 +113,7 @@
}
},
"deleteUserAccount": {
"name": "Effacer les données",
"name": "Supprimer un compte utilisateur",
"contributionsCount": "Supprimer mes {count} postes",
"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.",
@ -749,4 +749,4 @@
"donate-now": "Faites un don",
"amount-of-total": "{amount} de {total} € collectés"
}
}
}

View File

@ -114,6 +114,8 @@
},
"deleteUserAccount": {
"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!",
"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!",
@ -236,10 +238,10 @@
"description": ""
},
"donations": {
"name": "",
"goal": "",
"progress": "",
"successfulUpdate": ""
"name": "Info donazioni",
"goal": "Donazioni mensili necessarie",
"progress": "Donazioni raccolte finora",
"successfulUpdate": "Informazioni sulle donazioni aggiornate con successo!"
}
},
"post": {
@ -743,8 +745,8 @@
}
},
"donations": {
"donations-for": "",
"donate-now": "",
"amount-of-total": ""
"donations-for": "Donazioni per",
"donate-now": "Dona ora",
"amount-of-total": "{amount} of {total} € collezionato"
}
}

View File

@ -321,7 +321,63 @@
"disabledBy": "отключены",
"reasonCategory": "Категория",
"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": {
@ -501,7 +557,9 @@
"invalid-invitation-token": "Похоже, что приглашение уже использовалось. Ссылки на приглашения можно использовать только один раз."
},
"submit": "Создать учетную запись",
"success": "Письмо со ссылкой для завершения регистрации было отправлено на <b> {email} <\/b>"
"success": "Письмо со ссылкой для завершения регистрации было отправлено на <b> {email} <\/b>",
"no-commercial": "У меня нет коммерческих намерений, и я не представляю коммерческое предприятие или организацию.",
"no-political": "Я не от имени какой-либо партии или политической организации в сети."
}
},
"create-user-account": {
@ -742,6 +800,10 @@
"addition": {
"title": "Кроме того, мы регулярно проводим мероприятия, где ты также можешь\nподелиться своими впечатлениями и задать вопросы. Здесь ты можешь найти текущий обзор:",
"description": "<a href=\"https:\/\/human-connection.org\/events\/\" target=\"_blank\" > https:\/\/human-connection.org\/events\/ <\/a>"
},
"no-commercial-use": {
"title": "Нет коммерческого использования",
"description": "Использование Human Connection сети не допускается в коммерческих целях. Это включает, но не ограничивается рекламой продуктов с коммерческими целями, размещением партнерских ссылок, прямым привлечением пожертвований или предоставлением финансовой поддержки для целей, которые не признаются благотворительными для целей налогообложения."
}
},
"donations": {

View File

@ -58,7 +58,7 @@
},
"dependencies": {
"@human-connection/styleguide": "0.5.22",
"@nuxtjs/apollo": "^4.0.0-rc18",
"@nuxtjs/apollo": "^4.0.0-rc19",
"@nuxtjs/axios": "~5.8.0",
"@nuxtjs/dotenv": "~1.4.1",
"@nuxtjs/pwa": "^3.0.0-beta.19",
@ -80,7 +80,6 @@
"nuxt-dropzone": "^1.0.4",
"nuxt-env": "~0.1.0",
"stack-utils": "^1.0.2",
"string-hash": "^1.1.3",
"tippy.js": "^4.3.5",
"tiptap": "~1.26.3",
"tiptap-extensions": "~1.28.5",
@ -116,7 +115,7 @@
"babel-plugin-require-context-hook": "^1.0.0",
"babel-preset-vue": "~2.0.2",
"core-js": "~2.6.10",
"css-loader": "~3.3.0",
"css-loader": "~3.3.2",
"eslint": "~6.7.2",
"eslint-config-prettier": "~6.7.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 { filterPosts } from '~/graphql/PostQuery.js'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
export default {
components: {
@ -151,27 +152,7 @@ export default {
first: this.pageSize,
orderBy: ['pinned_asc', this.orderBy],
},
updateQuery: (previousResult, { fetchMoreResult }) => {
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
},
updateQuery: UpdateQuery(this, { $state, pageKey: 'Post' }),
})
},
deletePost(deletedPost) {

View File

@ -283,6 +283,7 @@ import { profilePagePosts } from '~/graphql/PostQuery'
import UserQuery from '~/graphql/User'
import { Block, Unblock } from '~/graphql/settings/BlockedUsers'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
const tabToFilterMapping = ({ tab, id }) => {
return {
@ -385,27 +386,7 @@ export default {
first: this.pageSize,
orderBy: 'createdAt_desc',
},
updateQuery: (previousResult, { fetchMoreResult }) => {
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
},
updateQuery: UpdateQuery(this, { $state, pageKey: 'profilePagePosts' }),
})
},
resetPostList() {

View File

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

View File

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

View File

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

View File

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

View File

@ -1574,14 +1574,14 @@
webpack-node-externals "^1.7.2"
webpackbar "^4.0.0"
"@nuxtjs/apollo@^4.0.0-rc18":
version "4.0.0-rc18"
resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc18.tgz#0069cae64f414ed879d20de00881986dca6bb26c"
integrity sha512-DTwRw9XLJKyphZiVwtKn4hE6Vfn6BlxEDWFBMTXpKE3XUKpg5+Qcgr8GstkiKtWbOuNQi660KdZReJ48R8bxgQ==
"@nuxtjs/apollo@^4.0.0-rc19":
version "4.0.0-rc19"
resolved "https://registry.yarnpkg.com/@nuxtjs/apollo/-/apollo-4.0.0-rc19.tgz#145b50c8e0185dac83c37f48ab685861f9005850"
integrity sha512-OCUxdhz09vTA7jq4KrhdYw23PRXS4yHWST99Ohc1oSUiZUyNrmQc+VUNAz9bhSVjfHABrP1NP2FzKnBE1iEZhA==
dependencies:
cross-fetch "^3.0.4"
universal-cookie "^4.0.2"
vue-apollo "^3.0.1"
vue-apollo "^3.0.2"
vue-cli-plugin-apollo "^0.21.3"
webpack-node-externals "^1.7.2"
@ -6176,10 +6176,10 @@ css-has-pseudo@^0.10.0:
postcss "^7.0.6"
postcss-selector-parser "^5.0.0-rc.4"
css-loader@^3.0.0, css-loader@^3.2.0, css-loader@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.3.0.tgz#65f889807baec3197313965d6cda9899f936734d"
integrity sha512-x9Y1vvHe5RR+4tzwFdWExPueK00uqFTCw7mZy+9aE/X1SKWOArm5luaOrtJ4d05IpOwJ6S86b/tVcIdhw1Bu4A==
css-loader@^3.0.0, css-loader@^3.2.0, css-loader@~3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.3.2.tgz#41b2086528aa4fbf8c0692e874bc14f081129b21"
integrity sha512-4XSiURS+YEK2fQhmSaM1onnUm0VKWNf6WWBYjkp9YbSDGCBTVZ5XOM6Gkxo8tLgQlzkZOBJvk9trHlDk4gjEYg==
dependencies:
camelcase "^5.3.1"
cssesc "^3.0.0"
@ -14302,11 +14302,6 @@ serve-static@1.14.1, serve-static@^1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
dependencies:
encodeurl "~1.0.2"
escape-html "~1.0.3"
parseurl "~1.3.3"
send "0.17.1"
server-destroy@^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"
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:
version "2.0.0"
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"
integrity sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==
vue-apollo@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.1.tgz#b7c24b6d6032bf707be7872e6615d59aa6621241"
integrity sha512-NM+kWbPGV5bnRMK7BmMJMxoT1NqPjVAYf+MsjPDyfQNgyVEHSIObRVqLQDIs56PYQSC6YOGa0luo6Ykjj6rrPw==
vue-apollo@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.2.tgz#b198ecfa3765850a0b9f2b84ffaa7fbd8ec15f52"
integrity sha512-lrKyTT1L5mjDEp7nyqnTRJwD/kTpLDBIqFfZ+TGQVivjlUz6o5VA0pLYGCx5cGa1gEF/ERWc0AEdNSdKgs7Ygg==
dependencies:
chalk "^2.4.2"
serialize-javascript "^2.1.0"

View File

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