diff --git a/backend/.env.template b/backend/.env.template index 239046dd32..dd46846a99 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -29,4 +29,4 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" -CATEGORIES_ACTIVE=false \ No newline at end of file +CATEGORIES_ACTIVE=false diff --git a/backend/package.json b/backend/package.json index 62188a650c..9aa7f539fb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "dev": "nodemon --exec babel-node src/ -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql", "lint": "eslint src --config .eslintrc.js", - "test": "cross-env NODE_ENV=test jest --forceExit --detectOpenHandles --runInBand --coverage", + "test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --forceExit --detectOpenHandles --runInBand --coverage", "db:clean": "babel-node src/db/clean.js", "db:reset": "yarn run db:clean", "db:seed": "babel-node src/db/seed.js", @@ -103,7 +103,7 @@ "mustache": "^4.2.0", "neo4j-driver": "^4.0.2", "neo4j-graphql-js": "^2.11.5", - "neode": "^0.4.7", + "neode": "^0.4.8", "node-fetch": "~2.6.1", "nodemailer": "^6.4.4", "nodemailer-html-to-text": "^3.2.0", diff --git a/backend/src/constants/categories.js b/backend/src/constants/categories.js new file mode 100644 index 0000000000..64ceb9021c --- /dev/null +++ b/backend/src/constants/categories.js @@ -0,0 +1,3 @@ +// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` +export const CATEGORIES_MIN = 1 +export const CATEGORIES_MAX = 3 diff --git a/backend/src/constants/groups.js b/backend/src/constants/groups.js new file mode 100644 index 0000000000..b4a6063f10 --- /dev/null +++ b/backend/src/constants/groups.js @@ -0,0 +1,2 @@ +// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` +export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js new file mode 100644 index 0000000000..f059706508 --- /dev/null +++ b/backend/src/db/graphql/authentications.js @@ -0,0 +1,29 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const signupVerificationMutation = gql` + mutation ( + $password: String! + $email: String! + $name: String! + $slug: String + $nonce: String! + $termsAndConditionsAgreedVersion: String! + ) { + SignupVerification( + email: $email + password: $password + name: $name + slug: $slug + nonce: $nonce + termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion + ) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js new file mode 100644 index 0000000000..2a611f3247 --- /dev/null +++ b/backend/src/db/graphql/groups.js @@ -0,0 +1,95 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createGroupMutation = gql` + mutation ( + $id: ID + $name: String! + $slug: String + $about: String + $description: String! + $groupType: GroupType! + $actionRadius: GroupActionRadius! + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + description: $description + groupType: $groupType + actionRadius: $actionRadius + categoryIds: $categoryIds + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + } + } +` + +// ------ queries + +export const groupQuery = gql` + query ( + $isMember: Boolean + $id: ID + $name: String + $slug: String + $createdAt: String + $updatedAt: String + $about: String + $description: String + $locationName: String + $first: Int + $offset: Int + $orderBy: [_GroupOrdering] + $filter: _GroupFilter + ) { + Group( + isMember: $isMember + id: $id + name: $name + slug: $slug + createdAt: $createdAt + updatedAt: $updatedAt + about: $about + description: $description + locationName: $locationName + first: $first + offset: $offset + orderBy: $orderBy + filter: $filter + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + categories { + id + slug + name + icon + } + } + } +` diff --git a/backend/src/db/graphql/posts.js b/backend/src/db/graphql/posts.js new file mode 100644 index 0000000000..3277af8207 --- /dev/null +++ b/backend/src/db/graphql/posts.js @@ -0,0 +1,15 @@ +import gql from 'graphql-tag' + +// ------ mutations + +export const createPostMutation = gql` + mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { + slug + } + } +` + +// ------ queries + +// fill queries in here diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 377caf0b0e..938ebef020 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -59,11 +59,11 @@ class Store { const session = driver.session() await createDefaultAdminUser(session) const writeTxResultPromise = session.writeTransaction(async (txc) => { - await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices + await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices and contraints return Promise.all( [ - 'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])', + 'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])', 'CALL db.index.fulltext.createNodeIndex("tag_fulltext_search",["Tag"],["id"])', ].map((statement) => txc.run(statement)), ) diff --git a/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js new file mode 100644 index 0000000000..b87e5632a3 --- /dev/null +++ b/backend/src/db/migrations/20220803060819-create_fulltext_indices_and_unique_keys_for_groups.js @@ -0,0 +1,66 @@ +import { getDriver } from '../../db/neo4j' + +export const description = ` + We introduced a new node label 'Group' and we need two primary keys 'id' and 'slug' for it. + Additional we like to have fulltext indices the keys 'name', 'slug', 'about', and 'description'. +` + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + CREATE CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.createNodeIndex("group_fulltext_search",["Group"],["name", "slug", "about", "description"]) + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.id IS UNIQUE + `) + await transaction.run(` + DROP CONSTRAINT ON ( group:Group ) ASSERT group.slug IS UNIQUE + `) + await transaction.run(` + CALL db.index.fulltext.drop("group_fulltext_search") + `) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} diff --git a/backend/src/helpers/jest.js b/backend/src/helpers/jest.js index 201d68c141..ecfc1a042a 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,3 +1,7 @@ +// TODO: can be replaced with: (which is no a fake) +// import gql from 'graphql-tag' +// See issue: https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/5152 + //* This is a fake ES2015 template string, just to benefit of syntax // highlighting of `gql` template strings in certain editors. export function gql(strings) { diff --git a/backend/src/middleware/excerptMiddleware.js b/backend/src/middleware/excerptMiddleware.js index 40a6a6ae40..ca061609a0 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -2,25 +2,25 @@ import trunc from 'trunc-html' export default { Mutation: { + CreateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, 120).html + return resolve(root, args, context, info) + }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, CreateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, UpdateComment: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 180).html - const result = await resolve(root, args, context, info) - return result + return resolve(root, args, context, info) }, }, } diff --git a/backend/src/middleware/helpers/cleanHtml.js b/backend/src/middleware/helpers/cleanHtml.js index 72976b43cc..ac71f6bdce 100644 --- a/backend/src/middleware/helpers/cleanHtml.js +++ b/backend/src/middleware/helpers/cleanHtml.js @@ -1,6 +1,13 @@ import sanitizeHtml from 'sanitize-html' import linkifyHtml from 'linkifyjs/html' +export const removeHtmlTags = (input) => { + return sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }) +} + const standardSanitizeHtmlOptions = { allowedTags: [ 'img', diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js index 3cf760f310..0872529753 100644 --- a/backend/src/middleware/languages/languages.js +++ b/backend/src/middleware/languages/languages.js @@ -1,12 +1,5 @@ import LanguageDetect from 'languagedetect' -import sanitizeHtml from 'sanitize-html' - -const removeHtmlTags = (input) => { - return sanitizeHtml(input, { - allowedTags: [], - allowedAttributes: {}, - }) -} +import { removeHtmlTags } from '../helpers/cleanHtml.js' const setPostLanguage = (text) => { const lngDetector = new LanguageDetect() diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b10389f501..99dcfc0cde 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -114,6 +114,7 @@ export default shield( reports: isModerator, statistics: allow, currentUser: allow, + Group: isAuthenticated, Post: allow, profilePagePosts: allow, Comment: allow, @@ -140,6 +141,7 @@ export default shield( Signup: or(publicRegistration, inviteRegistration, isAdmin), SignupVerification: allow, UpdateUser: onlyYourself, + CreateGroup: isAuthenticated, CreatePost: isAuthenticated, UpdatePost: isAuthor, DeletePost: isAuthor, diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 165235be9d..2a965c87f0 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -26,6 +26,10 @@ export default { args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User'))) return resolve(root, args, context, info) }, + CreateGroup: async (resolve, root, args, context, info) => { + args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Group'))) + return resolve(root, args, context, info) + }, CreatePost: async (resolve, root, args, context, info) => { args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) return resolve(root, args, context, info) diff --git a/backend/src/middleware/slugify/uniqueSlug.js b/backend/src/middleware/slugify/uniqueSlug.js index 7cfb89c193..41d58ece3d 100644 --- a/backend/src/middleware/slugify/uniqueSlug.js +++ b/backend/src/middleware/slugify/uniqueSlug.js @@ -1,4 +1,5 @@ import slugify from 'slug' + export default async function uniqueSlug(string, isUnique) { const slug = slugify(string || 'anonymous', { lower: true, diff --git a/backend/src/middleware/slugifyMiddleware.spec.js b/backend/src/middleware/slugifyMiddleware.spec.js index 7c6f18ab19..3fea526eef 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,29 +1,33 @@ -import Factory, { cleanDatabase } from '../db/factories' -import { gql } from '../helpers/jest' import { getNeode, getDriver } from '../db/neo4j' import createServer from '../server' import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../db/factories' +import { createGroupMutation } from '../db/graphql/groups' +import { createPostMutation } from '../db/graphql/posts' +import { signupVerificationMutation } from '../db/graphql/authentications' -let mutate let authenticatedUser let variables const driver = getDriver() const neode = getNeode() +const descriptionAdditional100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' + +const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, +}) + +const { mutate } = createTestClient(server) beforeAll(async () => { await cleanDatabase() - - const { server } = createServer({ - context: () => { - return { - driver, - neode, - user: authenticatedUser, - } - }, - }) - mutate = createTestClient(server).mutate }) afterAll(async () => { @@ -57,15 +61,136 @@ afterEach(async () => { }) describe('slugifyMiddleware', () => { - describe('CreatePost', () => { + describe('CreateGroup', () => { const categoryIds = ['cat9'] - const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { - slug - } + + beforeEach(() => { + variables = { + ...variables, + name: 'The Best Group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + categoryIds, } - ` + }) + + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + name: 'The Best Group', + slug: 'the-best-group', + about: 'Some about', + description: 'Some description' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'national', + }, + }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + slug: 'the-group', + }, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'the-group', + }, + }, + }) + }) + }) + + describe('if slug exists', () => { + beforeEach(async () => { + await mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + name: 'Pre-Existing Group', + slug: 'pre-existing-group', + about: 'As an about', + }, + }) + }) + + it('chooses another slug', async () => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + } + await expect( + mutate({ + mutation: createGroupMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreateGroup: { + slug: 'pre-existing-group-1', + }, + }, + }) + }) + + describe('but if the client specifies a slug', () => { + it('rejects CreateGroup', async (done) => { + variables = { + ...variables, + name: 'Pre-Existing Group', + about: 'As an about', + slug: 'pre-existing-group', + } + try { + await expect( + mutate({ mutation: createGroupMutation, variables }), + ).resolves.toMatchObject({ + errors: [ + { + message: 'Group with this slug already exists!', + }, + ], + }) + done() + } catch (error) { + throw new Error(` + ${error} + + Probably your database has no unique constraints! + + To see all constraints go to http://localhost:7474/browser/ and + paste the following: + \`\`\` + CALL db.constraints(); + \`\`\` + + Learn how to setup the database here: + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints + `) + } + }) + }) + }) + }) + + describe('CreatePost', () => { + const categoryIds = ['cat9'] beforeEach(() => { variables = { @@ -76,18 +201,38 @@ describe('slugifyMiddleware', () => { } }) - it('generates a slug based on title', async () => { - await expect( - mutate({ - mutation: createPostMutation, - variables, - }), - ).resolves.toMatchObject({ - data: { - CreatePost: { - slug: 'i-am-a-brand-new-post', + describe('if slug not exists', () => { + it('generates a slug based on title', async () => { + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + slug: 'i-am-a-brand-new-post', + }, + }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: createPostMutation, + variables: { + ...variables, + slug: 'the-post', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + slug: 'the-post', + }, }, - }, + }) }) }) @@ -160,7 +305,7 @@ describe('slugifyMiddleware', () => { \`\`\` Learn how to setup the database here: - https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints `) } }) @@ -169,28 +314,6 @@ describe('slugifyMiddleware', () => { }) describe('SignupVerification', () => { - const mutation = gql` - mutation ( - $password: String! - $email: String! - $name: String! - $slug: String - $nonce: String! - $termsAndConditionsAgreedVersion: String! - ) { - SignupVerification( - email: $email - password: $password - name: $name - slug: $slug - nonce: $nonce - termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion - ) { - slug - } - } - ` - beforeEach(() => { variables = { ...variables, @@ -211,18 +334,38 @@ describe('slugifyMiddleware', () => { }) }) - it('generates a slug based on name', async () => { - await expect( - mutate({ - mutation, - variables, - }), - ).resolves.toMatchObject({ - data: { - SignupVerification: { - slug: 'i-am-a-user', + describe('if slug not exists', () => { + it('generates a slug based on name', async () => { + await expect( + mutate({ + mutation: signupVerificationMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + SignupVerification: { + slug: 'i-am-a-user', + }, }, - }, + }) + }) + + it('generates a slug based on given slug', async () => { + await expect( + mutate({ + mutation: signupVerificationMutation, + variables: { + ...variables, + slug: 'the-user', + }, + }), + ).resolves.toMatchObject({ + data: { + SignupVerification: { + slug: 'the-user', + }, + }, + }) }) }) @@ -237,7 +380,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -260,7 +403,7 @@ describe('slugifyMiddleware', () => { it('rejects SignupVerification (on FAIL Neo4j constraints may not defined in database)', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ diff --git a/backend/src/models/Group.js b/backend/src/models/Group.js new file mode 100644 index 0000000000..a75ad518f9 --- /dev/null +++ b/backend/src/models/Group.js @@ -0,0 +1,46 @@ +import { v4 as uuid } from 'uuid' + +export default { + id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests + name: { type: 'string', disallow: [null], min: 3 }, + slug: { type: 'string', unique: 'true', regex: /^[a-z0-9_-]+$/, lowercase: true }, + + createdAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + + avatar: { + type: 'relationship', + relationship: 'AVATAR_IMAGE', + target: 'Image', + direction: 'out', + }, + + about: { type: 'string', allow: [null, ''] }, + description: { type: 'string', disallow: [null], min: 100 }, + descriptionExcerpt: { type: 'string', allow: [null] }, + groupType: { type: 'string', default: 'public' }, + actionRadius: { type: 'string', default: 'regional' }, + + myRole: { type: 'string', default: 'pending' }, + + locationName: { type: 'string', allow: [null] }, + + isIn: { + type: 'relationship', + relationship: 'IS_IN', + target: 'Location', + direction: 'out', + }, +} diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index 102acde6ab..c64d1fd374 100644 --- a/backend/src/models/User.spec.js +++ b/backend/src/models/User.spec.js @@ -55,7 +55,7 @@ describe('slug', () => { \`\`\` Learn how to setup the database here: - https://docs.human-connection.org/human-connection/backend#database-indices-and-constraints + https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/backend/README.md#database-indices-and-constraints `) } }) diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 8d6a021ab0..d476e5f9b1 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -4,6 +4,7 @@ export default { Image: require('./Image.js').default, Badge: require('./Badge.js').default, User: require('./User.js').default, + Group: require('./Group.js').default, EmailAddress: require('./EmailAddress.js').default, UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js').default, SocialMedia: require('./SocialMedia.js').default, diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 0000000000..5737f5505f --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,121 @@ +import { v4 as uuid } from 'uuid' +import { UserInputError } from 'apollo-server' +import CONFIG from '../../config' +import { CATEGORIES_MIN, CATEGORIES_MAX } from '../../constants/categories' +import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '../../constants/groups' +import { removeHtmlTags } from '../../middleware/helpers/cleanHtml.js' +import Resolver from './helpers/Resolver' + +export default { + Query: { + Group: async (_object, params, context, _resolveInfo) => { + const { isMember } = params + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + let groupCypher + if (isMember === true) { + groupCypher = ` + MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group) + RETURN group {.*, myRole: membership.role} + ` + } else { + if (isMember === false) { + groupCypher = ` + MATCH (group:Group) + WHERE NOT (:User {id: $userId})-[:MEMBER_OF]->(group) + RETURN group {.*, myRole: NULL} + ` + } else { + groupCypher = ` + MATCH (group:Group) + OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) + RETURN group {.*, myRole: membership.role} + ` + } + } + const result = await txc.run(groupCypher, { + userId: context.user.id, + }) + return result.records.map((record) => record.get('group')) + }) + try { + return await readTxResultPromise + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + }, + Mutation: { + CreateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + if (CONFIG.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) { + throw new UserInputError('Too view categories!') + } + if (CONFIG.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) { + throw new UserInputError('Too many categories!') + } + if ( + params.description === undefined || + params.description === null || + removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN + ) { + throw new UserInputError('Description too short!') + } + params.id = params.id || uuid() + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const categoriesCypher = + CONFIG.CATEGORIES_ACTIVE && categoryIds + ? ` + WITH group, membership + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (group)-[:CATEGORIZED]->(category) + ` + : '' + const ownerCreateGroupTransactionResponse = await transaction.run( + ` + CREATE (group:Group) + SET group += $params + SET group.createdAt = toString(datetime()) + SET group.updatedAt = toString(datetime()) + WITH group + MATCH (owner:User {id: $userId}) + MERGE (owner)-[:CREATED]->(group) + MERGE (owner)-[membership:MEMBER_OF]->(group) + SET membership.createdAt = toString(datetime()) + SET membership.updatedAt = toString(datetime()) + SET membership.role = 'owner' + ${categoriesCypher} + RETURN group {.*, myRole: membership.role} + `, + { userId: context.user.id, categoryIds, params }, + ) + const [group] = await ownerCreateGroupTransactionResponse.records.map((record) => + record.get('group'), + ) + return group + }) + try { + const group = await writeTxResultPromise + return group + } catch (error) { + if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(error) + } finally { + session.close() + } + }, + }, + Group: { + ...Resolver('Group', { + hasMany: { + categories: '-[:CATEGORIZED]->(related:Category)', + }, + }), + }, +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js new file mode 100644 index 0000000000..707558a063 --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,320 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { createGroupMutation, groupQuery } from '../../db/graphql/groups' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import CONFIG from '../../config' + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let authenticatedUser +let user + +const categoryIds = ['cat9', 'cat4', 'cat15'] +const descriptionAdditional100 = + ' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789' +let variables = {} + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +beforeEach(async () => { + variables = {} + user = await Factory.build( + 'user', + { + id: 'current-user', + name: 'TestUser', + }, + { + email: 'test@example.org', + password: '1234', + }, + ) + await Promise.all([ + neode.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + slug: 'democracy-politics', + icon: 'university', + }), + neode.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + slug: 'environment-nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + slug: 'consumption-sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: 'Animal Protection', + slug: 'animal-protection', + icon: 'paw', + }), + ]) + authenticatedUser = null +}) + +// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543 +afterEach(async () => { + await cleanDatabase() +}) + +describe('Group', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await query({ query: groupQuery, variables: {} }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + let otherUser + + beforeEach(async () => { + otherUser = await Factory.build( + 'user', + { + id: 'other-user', + name: 'Other TestUser', + }, + { + email: 'test2@example.org', + password: '1234', + }, + ) + authenticatedUser = await otherUser.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'others-group', + name: 'Uninteresting Group', + about: 'We will change nothing!', + description: 'We love it like it is!?' + descriptionAdditional100, + groupType: 'closed', + actionRadius: 'global', + categoryIds, + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createGroupMutation, + variables: { + id: 'my-group', + name: 'The Best Group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('query groups', () => { + describe('without any filters', () => { + it('finds all groups', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }), + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject(expected) + }) + }) + + describe('isMember = true', () => { + it('finds only groups where user is member', async () => { + const expected = { + data: { + Group: [ + { + id: 'my-group', + slug: 'the-best-group', + myRole: 'owner', + }, + ], + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: true } }), + ).resolves.toMatchObject(expected) + }) + }) + + describe('isMember = false', () => { + it('finds only groups where user is not(!) member', async () => { + const expected = { + data: { + Group: expect.arrayContaining([ + expect.objectContaining({ + id: 'others-group', + slug: 'uninteresting-group', + myRole: null, + }), + ]), + }, + errors: undefined, + } + await expect( + query({ query: groupQuery, variables: { isMember: false } }), + ).resolves.toMatchObject(expected) + }) + }) + }) + }) +}) + +describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + description: 'Some description' + descriptionAdditional100, + groupType: 'public', + actionRadius: 'regional', + categoryIds, + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + const { errors } = await mutate({ mutation: createGroupMutation, variables }) + expect(errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + it('creates a group', async () => { + const expected = { + data: { + CreateGroup: { + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('assigns the authenticated user as owner', async () => { + const expected = { + data: { + CreateGroup: { + name: 'The Best Group', + myRole: 'owner', + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('has "disabled" and "deleted" default to "false"', async () => { + const expected = { data: { CreateGroup: { disabled: false, deleted: false } } } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + describe('description', () => { + describe('length without HTML', () => { + describe('less then 100 chars', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { + ...variables, + description: + '0123456789' + + '0123456789', + }, + }) + expect(errors[0]).toHaveProperty('message', 'Description too short!') + }) + }) + }) + }) + + describe('categories', () => { + beforeEach(() => { + CONFIG.CATEGORIES_ACTIVE = true + }) + + describe('not even one', () => { + it('throws error: "Too view categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: null }, + }) + expect(errors[0]).toHaveProperty('message', 'Too view categories!') + }) + }) + + describe('four', () => { + it('throws error: "Too many categories!"', async () => { + const { errors } = await mutate({ + mutation: createGroupMutation, + variables: { ...variables, categoryIds: ['cat9', 'cat4', 'cat15', 'cat27'] }, + }) + expect(errors[0]).toHaveProperty('message', 'Too many categories!') + }) + }) + }) + }) +}) diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 0000000000..221ed7f877 --- /dev/null +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -0,0 +1,7 @@ +enum GroupActionRadius { + regional + national + continental + global + interplanetary +} diff --git a/backend/src/schema/types/enum/GroupMemberRole.gql b/backend/src/schema/types/enum/GroupMemberRole.gql new file mode 100644 index 0000000000..dacdd4b526 --- /dev/null +++ b/backend/src/schema/types/enum/GroupMemberRole.gql @@ -0,0 +1,6 @@ +enum GroupMemberRole { + pending + usual + admin + owner +} diff --git a/backend/src/schema/types/enum/GroupType.gql b/backend/src/schema/types/enum/GroupType.gql new file mode 100644 index 0000000000..2cf2984748 --- /dev/null +++ b/backend/src/schema/types/enum/GroupType.gql @@ -0,0 +1,5 @@ +enum GroupType { + public + closed + hidden +} diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql new file mode 100644 index 0000000000..3165b4a448 --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,109 @@ +enum _GroupOrdering { + id_asc + id_desc + name_asc + name_desc + slug_asc + slug_desc + locationName_asc + locationName_desc + about_asc + about_desc + createdAt_asc + createdAt_desc + updatedAt_asc + updatedAt_desc +} + +type Group { + id: ID! + name: String! # title + slug: String! + + createdAt: String! + updatedAt: String! + deleted: Boolean + disabled: Boolean + + avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT") + + about: String # goal + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + locationName: String + + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") + + myRole: GroupMemberRole # if 'null' then the current user is no member +} + + +input _GroupFilter { + AND: [_GroupFilter!] + OR: [_GroupFilter!] + name_contains: String + slug_contains: String + about_contains: String + description_contains: String + groupType_in: [GroupType!] + actionRadius_in: [GroupActionRadius!] + myRole_in: [GroupMemberRole!] + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] +} + +type Query { + Group( + isMember: Boolean # if 'undefined' or 'null' then all groups + id: ID + name: String + slug: String + createdAt: String + updatedAt: String + about: String + description: String + locationName: String + first: Int + offset: Int + orderBy: [_GroupOrdering] + filter: _GroupFilter + ): [Group] + + AvailableGroupTypes: [GroupType]! + + AvailableGroupActionRadii: [GroupActionRadius]! + + AvailableGroupMemberRoles: [GroupMemberRole]! +} + +type Mutation { + CreateGroup( + id: ID + name: String! + slug: String + avatar: ImageInput + about: String + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + categoryIds: [ID] + locationName: String + ): Group + + UpdateGroup( + id: ID! + name: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + ): Group + + DeleteGroup(id: ID!): Group +} diff --git a/backend/src/schema/types/type/MEMBER_OF.gql b/backend/src/schema/types/type/MEMBER_OF.gql new file mode 100644 index 0000000000..edda989f61 --- /dev/null +++ b/backend/src/schema/types/type/MEMBER_OF.gql @@ -0,0 +1,5 @@ +type MEMBER_OF { + createdAt: String! + updatedAt: String! + role: GroupMemberRole! +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 871e73ad8e..a25e51079a 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -156,19 +156,19 @@ input _UserFilter { type Query { User( - id: ID - email: String # admins need to search for a user sometimes - name: String - slug: String - role: UserRole - locationName: String - about: String - createdAt: String - updatedAt: String - first: Int - offset: Int - orderBy: [_UserOrdering] - filter: _UserFilter + id: ID + email: String # admins need to search for a user sometimes + name: String + slug: String + role: UserRole + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] availableRoles: [UserRole]! @@ -197,19 +197,19 @@ enum Deletable { type Mutation { UpdateUser ( - id: ID! - name: String - email: String - slug: String - avatar: ImageInput - locationName: String - about: String - termsAndConditionsAgreedVersion: String - termsAndConditionsAgreedAt: String - allowEmbedIframes: Boolean - showShoutsPublicly: Boolean - sendNotificationEmails: Boolean - locale: String + id: ID! + name: String + email: String + slug: String + avatar: ImageInput + locationName: String + about: String + termsAndConditionsAgreedVersion: String + termsAndConditionsAgreedAt: String + allowEmbedIframes: Boolean + showShoutsPublicly: Boolean + sendNotificationEmails: Boolean + locale: String ): User DeleteUser(id: ID!, resource: [Deletable]): User diff --git a/backend/yarn.lock b/backend/yarn.lock index 24bd00b3a6..8c69a08145 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -997,9 +997,9 @@ tslib "1.11.1" "@hapi/address@2.x.x": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.2.tgz#1c794cd6dbf2354d1eb1ef10e0303f573e1c7222" - integrity sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q== + version "2.1.4" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" + integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== "@hapi/address@^4.0.1": version "4.0.1" @@ -1018,10 +1018,10 @@ resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== -"@hapi/hoek@8.x.x": - version "8.2.4" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.2.4.tgz#684a14f4ca35d46f44abc87dfc696e5e4fe8a020" - integrity sha512-Ze5SDNt325yZvNO7s5C4fXDscjJ6dcqLFXJQ/M7dZRQCewuDj2iDUuBi6jLQt+APbW9RjjVEvLr35FXuOEqjow== +"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" + integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== "@hapi/hoek@^9.0.0": version "9.0.0" @@ -1055,11 +1055,11 @@ integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== "@hapi/topo@3.x.x": - version "3.1.3" - resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.3.tgz#c7a02e0d936596d29f184e6d7fdc07e8b5efce11" - integrity sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ== + version "3.1.6" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" + integrity sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== dependencies: - "@hapi/hoek" "8.x.x" + "@hapi/hoek" "^8.3.0" "@hapi/topo@^5.0.0": version "5.0.0" @@ -2681,6 +2681,11 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -2850,6 +2855,14 @@ buffer@4.9.1: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + busboy@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" @@ -3929,7 +3942,7 @@ dot-prop@^4.1.0: dotenv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" - integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= + integrity sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ== dotenv@^6.1.0: version "6.2.0" @@ -5516,6 +5529,11 @@ ieee754@1.1.13, ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ienoopen@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" @@ -7528,18 +7546,19 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -neo4j-driver-bolt-connection@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.3.4.tgz#de642bb6a62ffc6ae2e280dccf21395b4d1705a2" - integrity sha512-yxbvwGav+N7EYjcEAINqL6D3CZV+ee2qLInpAhx+iNurwbl3zqtBGiVP79SZ+7tU++y3Q1fW5ofikH06yc+LqQ== +neo4j-driver-bolt-connection@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.7.tgz#0582d54de1f213e60c374209193d1f645ba523ea" + integrity sha512-6Q4hCtvWE6gzN64N09UqZqf/3rDl7FUWZZXiVQL0ZRbaMkJpZNC2NmrDIgGXYE05XEEbRBexf2tVv5OTYZYrow== dependencies: - neo4j-driver-core "^4.3.4" - text-encoding-utf-8 "^1.0.2" + buffer "^6.0.3" + neo4j-driver-core "^4.4.7" + string_decoder "^1.3.0" -neo4j-driver-core@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.3.4.tgz#b445a4fbf94dce8441075099bd6ac3133c1cf5ee" - integrity sha512-3tn3j6IRUNlpXeehZ9Xv7dLTZPB4a7APaoJ+xhQyMmYQO3ujDM4RFHc0pZcG+GokmaltT5pUCIPTDYx6ODdhcA== +neo4j-driver-core@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.7.tgz#d2475e107b3fea2b9d1c36b0c273da5c5a291c37" + integrity sha512-NhvVuQYgG7eO/vXxRaoJfkWUNkjvIpmCIS9UWU9Bbhb4V+wCOyX/MVOXqD0Yizhs4eyIkD7x90OXb79q+vi+oA== neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: version "4.0.2" @@ -7552,13 +7571,13 @@ neo4j-driver@^4.0.1, neo4j-driver@^4.0.2: uri-js "^4.2.2" neo4j-driver@^4.2.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.3.4.tgz#a54f0562f868ee94dff7509df74e3eb2c1f95a85" - integrity sha512-AGrsFFqnoZv4KhJdmKt4mOBV5mnxmV3+/t8KJTOM68jQuEWoy+RlmAaRRaCSU4eY586OFN/R8lg9MrJpZdSFjw== + version "4.4.7" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-4.4.7.tgz#51b3fb48241e66eb3be94e90032cc494c44e59f3" + integrity sha512-N7GddPhp12gVJe4eB84u5ik5SmrtRv8nH3rK47Qy7IUKnJkVEos/F1QjOJN6zt1jLnDXwDcGzCKK8XklYpzogw== dependencies: "@babel/runtime" "^7.5.5" - neo4j-driver-bolt-connection "^4.3.4" - neo4j-driver-core "^4.3.4" + neo4j-driver-bolt-connection "^4.4.7" + neo4j-driver-core "^4.4.7" rxjs "^6.6.3" neo4j-graphql-js@^2.11.5: @@ -7574,10 +7593,10 @@ neo4j-graphql-js@^2.11.5: lodash "^4.17.15" neo4j-driver "^4.0.1" -neode@^0.4.7: - version "0.4.7" - resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.7.tgz#033007b57a2ee167e9ee5537493086db08d005eb" - integrity sha512-YXlc187JRpeKCBcUIkY6nimXXG+Tvlopfe71/FPno2THrwmYt5mm0RPHZ+mXF2O1Xg6zvjKvOpCpDz2vHBfroQ== +neode@^0.4.8: + version "0.4.8" + resolved "https://registry.yarnpkg.com/neode/-/neode-0.4.8.tgz#0889b4fc7f1bf0b470b01fa5b8870373b5d47ad6" + integrity sha512-pb91NfCOg4Fj5o+98H+S2XYC+ByQfbdhwcc1UVuzuUQ0Ezzj+jWz8NmKWU8ZfCH6l4plk71yDAPd2eTwpt+Xvg== dependencies: "@hapi/joi" "^15.1.1" dotenv "^4.0.0" @@ -9603,7 +9622,7 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== diff --git a/webapp/.env.template b/webapp/.env.template index 0a4c3405fe..9776fcea29 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,4 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ -CATEGORIES_ACTIVE=false \ No newline at end of file +CATEGORIES_ACTIVE=false diff --git a/webapp/constants/categories.js b/webapp/constants/categories.js new file mode 100644 index 0000000000..64ceb9021c --- /dev/null +++ b/webapp/constants/categories.js @@ -0,0 +1,3 @@ +// this file is duplicated in `backend/src/constants/metadata.js` and `webapp/constants/metadata.js` +export const CATEGORIES_MIN = 1 +export const CATEGORIES_MAX = 3 diff --git a/webapp/constants/groups.js b/webapp/constants/groups.js new file mode 100644 index 0000000000..b4a6063f10 --- /dev/null +++ b/webapp/constants/groups.js @@ -0,0 +1,2 @@ +// this file is duplicated in `backend/src/constants/group.js` and `webapp/constants/group.js` +export const DESCRIPTION_WITHOUT_HTML_LENGTH_MIN = 100 // with removed HTML tags