diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 595c9d584..1fe27f6c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,10 @@ --- -name: 🐛 Bug Report +name: "\U0001F41B Bug Report" about: Create a report to help us to improve. +title: "\U0001F41B [Bug] XXX" labels: bug -title: 🐛 [Bug] +assignees: '' + --- ## :bug: Bug Report diff --git a/.github/ISSUE_TEMPLATE/devops_ticket.md b/.github/ISSUE_TEMPLATE/devops_ticket.md index 115664911..17533cd54 100644 --- a/.github/ISSUE_TEMPLATE/devops_ticket.md +++ b/.github/ISSUE_TEMPLATE/devops_ticket.md @@ -1,8 +1,10 @@ --- -name: 💥 DevOps Ticket +name: "\U0001F4A5 DevOps Ticket" about: Help us manage our deployed app. +title: "\U0001F4A5 [DevOps] XXX" labels: devops -title: 💥 [DevOps] +assignees: '' + --- ## 💥 DevOps Ticket diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md index cf72cd673..57eca6dfe 100644 --- a/.github/ISSUE_TEMPLATE/epic.md +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -1,9 +1,12 @@ --- -name: 🌟 Epic +name: "\U0001F31F Epic" about: Define a big development step. +title: "\U0001F31F [EPIC] XXX" labels: epic -title: 🌟 [EPIC] +assignees: '' + --- + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index beae80901..22cd5045e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,10 @@ --- -name: 🚀 Feature Request +name: "\U0001F680 Feature Request" about: Suggest an idea for this project. +title: "\U0001F680 [Feature] XXX" labels: feature -title: 🚀 [Feature] +assignees: '' + --- ## :rocket: Feature Request diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 40e6e381b..f2328dcc7 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -1,9 +1,12 @@ --- -name: 💬 Question +name: "\U0001F4AC Question" about: If you need help understanding ocelot.social. +title: "\U0001F4AC [Question] XXX" labels: question -title: 💬 [Question] +assignees: '' + --- + diff --git a/.github/ISSUE_TEMPLATE/refactor_tickets.md b/.github/ISSUE_TEMPLATE/refactor_tickets.md index d1841e35e..867c809ae 100644 --- a/.github/ISSUE_TEMPLATE/refactor_tickets.md +++ b/.github/ISSUE_TEMPLATE/refactor_tickets.md @@ -1,10 +1,11 @@ --- -name: 🔧 Refactor +name: "\U0001F527 Refactor" about: Help us improve our code by refactoring it. +title: "\U0001F527 [Refactor] XXX" labels: refactor -title: 🔧 [Refactor] +assignees: '' + --- ## 🔧 Refactor - diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8aa04ee..74f2c1dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ 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). -#### [1.0.9](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.8...1.0.9) +#### [1.1.0](https://github.com/Ocelot-Social-Community/Ocelot-Social/compare/1.0.8...1.1.0) +- feat: Make Categories Optional [`#5102`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5102) +- Update issue templates [`#5101`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5101) +- chore: 🍰 Betters Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5097`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5097) +- chore: 🍰 Release v1.0.9 [`#5095`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5095) - chore: 🍰 Automatic Deployment To `stage.ocelot.social` On Push To `master` Branch [`#5080`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5080) - change footer version-link [`#5091`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5091) - docs: 🍰 Add Neo4j Docu For Important Commands [`#5090`](https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/5090) diff --git a/backend/.env.template b/backend/.env.template index 239046dd3..dd46846a9 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 028b9295e..62188a650 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-backend", - "version": "1.0.9", + "version": "1.1.0", "description": "GraphQL Backend for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/backend/src/db/graphql/authentications.js b/backend/src/db/graphql/authentications.js new file mode 100644 index 000000000..f05970650 --- /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 000000000..e8da8e90b --- /dev/null +++ b/backend/src/db/graphql/groups.js @@ -0,0 +1,101 @@ +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 + # Wolle: owner { + # name + # } + } + } +` + +// ------ queries + +export const groupQuery = gql` + query ( + $isMember: Boolean + $id: ID, + $name: String, + $slug: String, + $createdAt: String + $updatedAt: String + $about: String, + $description: String, + # $groupType: GroupType!, + # $actionRadius: GroupActionRadius!, + # $categoryIds: [ID] + $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 + # groupType: $groupType + # actionRadius: $actionRadius + # categoryIds: $categoryIds + locationName: $locationName + first: $first + offset: $offset + orderBy: $orderBy + filter: $filter + ) { + id + name + slug + createdAt + updatedAt + disabled + deleted + about + description + groupType + actionRadius + myRole + # Wolle: owner { + # name + # } + } + } +` diff --git a/backend/src/db/graphql/posts.js b/backend/src/db/graphql/posts.js new file mode 100644 index 000000000..3277af820 --- /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 377caf0b0..938ebef02 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 000000000..b87e5632a --- /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 201d68c14..14317642b 100644 --- a/backend/src/helpers/jest.js +++ b/backend/src/helpers/jest.js @@ -1,3 +1,6 @@ +// TODO: can be replaced with, which is no a fake: +// import gql from 'graphql-tag' + //* 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 40a6a6ae4..cfaf7f1b0 100644 --- a/backend/src/middleware/excerptMiddleware.js +++ b/backend/src/middleware/excerptMiddleware.js @@ -2,6 +2,11 @@ import trunc from 'trunc-html' export default { Mutation: { + CreateGroup: async (resolve, root, args, context, info) => { + args.descriptionExcerpt = trunc(args.description, 120).html + const result = await resolve(root, args, context, info) + return result + }, CreatePost: async (resolve, root, args, context, info) => { args.contentExcerpt = trunc(args.content, 120).html const result = await resolve(root, args, context, info) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index b10389f50..99dcfc0cd 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 165235be9..2a965c87f 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 7cfb89c19..41d58ece3 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 7c6f18ab1..3c18e70b0 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.js +++ b/backend/src/middleware/slugifyMiddleware.spec.js @@ -1,8 +1,10 @@ -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 @@ -57,15 +59,136 @@ afterEach(async () => { }) describe('slugifyMiddleware', () => { + describe('CreateGroup', () => { + const categoryIds = ['cat9'] + + beforeEach(() => { + variables = { + ...variables, + name: 'The Best Group', + about: 'Some about', + description: 'Some description', + 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', + 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'] - const createPostMutation = gql` - mutation ($title: String!, $content: String!, $categoryIds: [ID]!, $slug: String) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds, slug: $slug) { - slug - } - } - ` beforeEach(() => { variables = { @@ -76,18 +199,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 +303,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 +312,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 +332,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 +378,7 @@ describe('slugifyMiddleware', () => { it('chooses another slug', async () => { await expect( mutate({ - mutation, + mutation: signupVerificationMutation, variables, }), ).resolves.toMatchObject({ @@ -260,7 +401,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 000000000..0cec02bf8 --- /dev/null +++ b/backend/src/models/Group.js @@ -0,0 +1,146 @@ +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] }, + + wasSeeded: 'boolean', // Wolle: used or needed? + // Wolle: owner: { + // type: 'relationship', + // relationship: 'OWNS', + // target: 'User', + // direction: 'in', + // }, + // Wolle: followedBy: { + // type: 'relationship', + // relationship: 'FOLLOWS', + // target: 'User', + // direction: 'in', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: correct this way? + // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, + // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, + // Wolle: emoted: { + // type: 'relationships', + // relationship: 'EMOTED', + // target: 'Post', + // direction: 'out', + // properties: { + // emotion: { + // type: 'string', + // valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], + // invalid: [null], + // }, + // }, + // eager: true, + // cascade: true, + // }, + // Wolle: blocked: { + // type: 'relationship', + // relationship: 'BLOCKED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: muted: { + // type: 'relationship', + // relationship: 'MUTED', + // target: 'User', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: notifications: { + // type: 'relationship', + // relationship: 'NOTIFIED', + // target: 'User', + // direction: 'in', + // }, + // Wolle inviteCodes: { + // type: 'relationship', + // relationship: 'GENERATED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: redeemedInviteCode: { + // type: 'relationship', + // relationship: 'REDEEMED', + // target: 'InviteCode', + // direction: 'out', + // }, + // Wolle: shouted: { + // type: 'relationship', + // relationship: 'SHOUTED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + isIn: { + type: 'relationship', + relationship: 'IS_IN', + target: 'Location', + direction: 'out', + }, + // Wolle: pinned: { + // type: 'relationship', + // relationship: 'PINNED', + // target: 'Post', + // direction: 'out', + // properties: { + // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + // }, + // }, + // Wolle: showShoutsPublicly: { + // type: 'boolean', + // default: false, + // }, + // Wolle: sendNotificationEmails: { + // type: 'boolean', + // default: true, + // }, + // Wolle: locale: { + // type: 'string', + // allow: [null], + // }, +} diff --git a/backend/src/models/User.spec.js b/backend/src/models/User.spec.js index 102acde6a..c64d1fd37 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 8d6a021ab..d476e5f9b 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 000000000..be07fecc6 --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,218 @@ +import { v4 as uuid } from 'uuid' +// Wolle: import { neo4jgraphql } from 'neo4j-graphql-js' +// Wolle: import { isEmpty } from 'lodash' +import { UserInputError } from 'apollo-server' +import CONFIG from '../../config' +// Wolle: import { mergeImage, deleteImage } from './images/images' +import Resolver from './helpers/Resolver' +// Wolle: import { filterForMutedUsers } from './helpers/filterForMutedUsers' + +// Wolle: const maintainPinnedPosts = (params) => { +// const pinnedPostFilter = { pinned: true } +// if (isEmpty(params.filter)) { +// params.filter = { OR: [pinnedPostFilter, {}] } +// } else { +// params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } +// } +// return params +// } + +export default { + Query: { + // Wolle: Post: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // // params = await maintainPinnedPosts(params) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + 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, + }) + const group = result.records.map((record) => record.get('group')) + return group + }) + try { + const group = await readTxResultPromise + return group + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, + }, + Mutation: { + CreateGroup: async (_parent, params, context, _resolveInfo) => { + const { categoryIds } = params + delete params.categoryIds + 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)-[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] = 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() + } + }, + // UpdatePost: async (_parent, params, context, _resolveInfo) => { + // const { categoryIds } = params + // const { image: imageInput } = params + // delete params.categoryIds + // delete params.image + // const session = context.driver.session() + // 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.writeTransaction((transaction) => { + // return transaction.run(cypherDeletePreviousRelations, { params }) + // }) + + // updatePostCypher += ` + // UNWIND $categoryIds AS categoryId + // MATCH (category:Category {id: categoryId}) + // MERGE (post)-[:CATEGORIZED]->(category) + // WITH post + // ` + // } + + // updatePostCypher += `RETURN post {.*}` + // const updatePostVariables = { categoryIds, params } + // try { + // const writeTxResultPromise = session.writeTransaction(async (transaction) => { + // const updatePostTransactionResponse = await transaction.run( + // updatePostCypher, + // updatePostVariables, + // ) + // const [post] = updatePostTransactionResponse.records.map((record) => record.get('post')) + // await mergeImage(post, 'HERO_IMAGE', imageInput, { transaction }) + // return post + // }) + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + // }, + + // DeletePost: async (object, args, context, resolveInfo) => { + // const session = context.driver.session() + // 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 + // RETURN post {.*} + // `, + // { postId: args.id }, + // ) + // const [post] = deletePostTransactionResponse.records.map((record) => record.get('post')) + // await deleteImage(post, 'HERO_IMAGE', { transaction }) + // return post + // }) + // try { + // const post = await writeTxResultPromise + // return post + // } finally { + // session.close() + // } + }, + Group: { + ...Resolver('Group', { + // Wolle: undefinedToNull: ['activityId', 'objectId', 'language', 'pinnedAt', 'pinned'], + hasMany: { + // Wolle: tags: '-[:TAGGED]->(related:Tag)', + categories: '-[:CATEGORIZED]->(related:Category)', + }, + // hasOne: { + // owner: '<-[:OWNS]-(related:User)', + // // Wolle: image: '-[:HERO_IMAGE]->(related:Image)', + // }, + // Wolle: count: { + // contributionsCount: + // '-[:WROTE]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', + // }, + // Wolle: boolean: { + // shoutedByCurrentUser: + // 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', + // viewedTeaserByCurrentUser: + // 'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', + // }, + }), + }, +} diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js new file mode 100644 index 000000000..58e5f37be --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,720 @@ +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' + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let authenticatedUser +let user + +const categoryIds = ['cat9', 'cat4', 'cat15'] +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', + icon: 'university', + }), + neode.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + neode.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + neode.create('Category', { + id: 'cat27', + name: '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!?', + groupType: 'closed', + actionRadius: 'international', + 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', + groupType: 'public', + actionRadius: 'regional', + categoryIds, + }, + }) + }) + + describe('query can fetch', () => { + it('groups where user is member (or owner in this case)', 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) + }) + + it('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) + }) + + it('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) + }) + }) + + // Wolle: describe('can be filtered', () => { + // let followedUser, happyPost, cryPost + // beforeEach(async () => { + // ;[followedUser] = await Promise.all([ + // Factory.build( + // 'user', + // { + // id: 'followed-by-me', + // name: 'Followed User', + // }, + // { + // email: 'followed@example.org', + // password: '1234', + // }, + // ), + // ]) + // ;[happyPost, cryPost] = await Promise.all([ + // Factory.build('post', { id: 'happy-post' }, { categoryIds: ['cat4'] }), + // Factory.build('post', { id: 'cry-post' }, { categoryIds: ['cat15'] }), + // Factory.build( + // 'post', + // { + // id: 'post-by-followed-user', + // }, + // { + // categoryIds: ['cat9'], + // author: followedUser, + // }, + // ), + // ]) + // }) + // describe('no filter', () => { + // it('returns all posts', async () => { + // const postQueryNoFilters = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // } + // } + // ` + // const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }] + // variables = { filter: {} } + // await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // }) + // }) + // }) + // /* it('by categories', async () => { + // const postQueryFilteredByCategories = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // categories { + // id + // } + // } + // } + // ` + // const expected = { + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // categories: [{ id: 'cat9' }], + // }, + // ], + // }, + // } + // variables = { ...variables, filter: { categories_some: { id_in: ['cat9'] } } } + // await expect( + // query({ query: postQueryFilteredByCategories, variables }), + // ).resolves.toMatchObject(expected) + // }) */ + // describe('by emotions', () => { + // const postQueryFilteredByEmotions = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // emotions { + // emotion + // } + // } + // } + // ` + // it('filters by single emotion', async () => { + // const expected = { + // data: { + // Post: [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // ], + // }, + // } + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject(expected) + // }) + // it('filters by multiple emotions', async () => { + // const expected = [ + // { + // id: 'happy-post', + // emotions: [{ emotion: 'happy' }], + // }, + // { + // id: 'cry-post', + // emotions: [{ emotion: 'cry' }], + // }, + // ] + // await user.relateTo(happyPost, 'emoted', { emotion: 'happy' }) + // await user.relateTo(cryPost, 'emoted', { emotion: 'cry' }) + // variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } } + // await expect( + // query({ query: postQueryFilteredByEmotions, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: expect.arrayContaining(expected), + // }, + // errors: undefined, + // }) + // }) + // }) + // it('by followed-by', async () => { + // const postQueryFilteredByUsersFollowed = gql` + // query Post($filter: _PostFilter) { + // Post(filter: $filter) { + // id + // author { + // id + // name + // } + // } + // } + // ` + // await user.relateTo(followedUser, 'following') + // variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } } + // await expect( + // query({ query: postQueryFilteredByUsersFollowed, variables }), + // ).resolves.toMatchObject({ + // data: { + // Post: [ + // { + // id: 'post-by-followed-user', + // author: { id: 'followed-by-me', name: 'Followed User' }, + // }, + // ], + // }, + // errors: undefined, + // }) + // }) + // }) + }) +}) + +describe('CreateGroup', () => { + beforeEach(() => { + variables = { + ...variables, + id: 'g589', + name: 'The Best Group', + slug: 'the-group', + about: 'We will change the world!', + description: 'Some description', + 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', + // Wolle: owner: { + // name: 'TestUser', + // }, + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: createGroupMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('`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('UpdatePost', () => { +// let author, newlyCreatedPost +// const updatePostMutation = gql` +// mutation ($id: ID!, $title: String!, $content: String!, $image: ImageInput) { +// UpdatePost(id: $id, title: $title, content: $content, image: $image) { +// id +// title +// content +// author { +// name +// slug +// } +// createdAt +// updatedAt +// } +// } +// ` +// beforeEach(async () => { +// author = await Factory.build('user', { slug: 'the-author' }) +// newlyCreatedPost = await Factory.build( +// 'post', +// { +// id: 'p9876', +// title: 'Old title', +// content: 'Old content', +// }, +// { +// author, +// categoryIds, +// }, +// ) + +// variables = { +// id: 'p9876', +// title: 'New title', +// content: 'New content', +// } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// authenticatedUser = null +// expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({ +// errors: [{ message: 'Not Authorised!' }], +// data: { UpdatePost: null }, +// }) +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: updatePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('updates a post', async () => { +// const expected = { +// data: { UpdatePost: { id: 'p9876', content: 'New content' } }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates a post, but maintains non-updated attributes', async () => { +// const expected = { +// data: { +// UpdatePost: { id: 'p9876', content: 'New content', createdAt: expect.any(String) }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// it('updates the updatedAt attribute', async () => { +// newlyCreatedPost = await newlyCreatedPost.toJson() +// const { +// data: { UpdatePost }, +// } = await mutate({ mutation: updatePostMutation, variables }) +// expect(newlyCreatedPost.updatedAt).toBeTruthy() +// expect(Date.parse(newlyCreatedPost.updatedAt)).toEqual(expect.any(Number)) +// expect(UpdatePost.updatedAt).toBeTruthy() +// expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number)) +// expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt) +// }) + +// /* describe('no new category ids provided for update', () => { +// it('resolves and keeps current categories', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// /* describe('given category ids', () => { +// beforeEach(() => { +// variables = { ...variables, categoryIds: ['cat27'] } +// }) + +// it('updates categories of a post', async () => { +// const expected = { +// data: { +// UpdatePost: { +// id: 'p9876', +// categories: expect.arrayContaining([{ id: 'cat27' }]), +// }, +// }, +// errors: undefined, +// } +// await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) */ + +// describe('params.image', () => { +// describe('is object', () => { +// beforeEach(() => { +// variables = { ...variables, image: { sensitive: true } } +// }) +// it('updates the image', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeTruthy() +// }) +// }) + +// describe('is null', () => { +// beforeEach(() => { +// variables = { ...variables, image: null } +// }) +// it('deletes the image', async () => { +// await expect(neode.all('Image')).resolves.toHaveLength(6) +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.all('Image')).resolves.toHaveLength(5) +// }) +// }) + +// describe('is undefined', () => { +// beforeEach(() => { +// delete variables.image +// }) +// it('keeps the image unchanged', async () => { +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// await mutate({ mutation: updatePostMutation, variables }) +// await expect(neode.first('Image', { sensitive: true })).resolves.toBeFalsy() +// }) +// }) +// }) +// }) +// }) + +// describe('DeletePost', () => { +// let author +// const deletePostMutation = gql` +// mutation ($id: ID!) { +// DeletePost(id: $id) { +// id +// deleted +// content +// contentExcerpt +// image { +// url +// } +// comments { +// deleted +// content +// contentExcerpt +// } +// } +// } +// ` + +// beforeEach(async () => { +// author = await Factory.build('user') +// await Factory.build( +// 'post', +// { +// id: 'p4711', +// title: 'I will be deleted', +// content: 'To be deleted', +// }, +// { +// image: Factory.build('image', { +// url: 'path/to/some/image', +// }), +// author, +// categoryIds, +// }, +// ) +// variables = { ...variables, id: 'p4711' } +// }) + +// describe('unauthenticated', () => { +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated but not the author', () => { +// beforeEach(async () => { +// authenticatedUser = await user.toJson() +// }) + +// it('throws authorization error', async () => { +// const { errors } = await mutate({ mutation: deletePostMutation, variables }) +// expect(errors[0]).toHaveProperty('message', 'Not Authorised!') +// }) +// }) + +// describe('authenticated as author', () => { +// beforeEach(async () => { +// authenticatedUser = await author.toJson() +// }) + +// it('marks the post as deleted and blacks out attributes', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) + +// describe('if there are comments on the post', () => { +// beforeEach(async () => { +// await Factory.build( +// 'comment', +// { +// content: 'to be deleted comment content', +// contentExcerpt: 'to be deleted comment content', +// }, +// { +// postId: 'p4711', +// }, +// ) +// }) + +// it('marks the comments as deleted', async () => { +// const expected = { +// data: { +// DeletePost: { +// id: 'p4711', +// deleted: true, +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// image: null, +// comments: [ +// { +// deleted: true, +// // Should we black out the comment content in the database, too? +// content: 'UNAVAILABLE', +// contentExcerpt: 'UNAVAILABLE', +// }, +// ], +// }, +// }, +// } +// await expect(mutate({ mutation: deletePostMutation, variables })).resolves.toMatchObject( +// expected, +// ) +// }) +// }) +// }) +// }) diff --git a/backend/src/schema/types/enum/GroupActionRadius.gql b/backend/src/schema/types/enum/GroupActionRadius.gql new file mode 100644 index 000000000..afc421133 --- /dev/null +++ b/backend/src/schema/types/enum/GroupActionRadius.gql @@ -0,0 +1,6 @@ +enum GroupActionRadius { + regional + national + continental + international +} diff --git a/backend/src/schema/types/enum/GroupMemberRole.gql b/backend/src/schema/types/enum/GroupMemberRole.gql new file mode 100644 index 000000000..dacdd4b52 --- /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 000000000..2cf298474 --- /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 000000000..b8e00f0ee --- /dev/null +++ b/backend/src/schema/types/type/Group.gql @@ -0,0 +1,257 @@ +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 + # Wolle: needed? locale_asc + # locale_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 + + # Wolle: needed? + # socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + + # Wolle: owner: User @relation(name: "OWNS", direction: "IN") + + # Wolle: showShoutsPublicly: Boolean + # Wolle: sendNotificationEmails: Boolean + # Wolle: needed? locale: String + # members: [User]! @relation(name: "MEMBERS", direction: "OUT") + # membersCount: Int! + # @cypher(statement: "MATCH (this)-[:MEMBERS]->(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") + # Wolle: followedByCount: Int! + # @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") + + # Wolle: inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") + # Wolle: redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") + + # Is the currently logged in user following that user? + # Wolle: followedByCurrentUser: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(u) >= 1 + # """ + # ) + + # Wolle: isBlocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + # Wolle: blocked: Boolean! + # @cypher( + # statement: """ + # MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # Wolle: isMuted: Boolean! + # @cypher( + # statement: """ + # MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId}) + # RETURN COUNT(user) >= 1 + # """ + # ) + + # contributions: [WrittenPost]! + # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! + # @cypher( + # statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp" + # ) + # Wolle: needed? + # contributions: [Post]! @relation(name: "WROTE", direction: "OUT") + # contributionsCount: Int! + # @cypher( + # statement: """ + # MATCH (this)-[:WROTE]->(r:Post) + # WHERE NOT r.deleted = true AND NOT r.disabled = true + # RETURN COUNT(r) + # """ + # ) + + # Wolle: comments: [Comment]! @relation(name: "WROTE", direction: "OUT") + # commentedCount: Int! + # @cypher( + # statement: "MATCH (this)-[:WROTE]->(:Comment)-[:COMMENTS]->(p:Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))" + # ) + + # Wolle: shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") + # shoutedCount: Int! + # @cypher( + # statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)" + # ) + + # Wolle: badges: [Badge]! @relation(name: "REWARDED", direction: "IN") + # badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") + + # Wolle: emotions: [EMOTED] +} + + +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!] + # Wolle: + # friends: _GroupFilter + # friends_not: _GroupFilter + # friends_in: [_GroupFilter!] + # friends_not_in: [_GroupFilter!] + # friends_some: _GroupFilter + # friends_none: _GroupFilter + # friends_single: _GroupFilter + # friends_every: _GroupFilter + # following: _GroupFilter + # following_not: _GroupFilter + # following_in: [_GroupFilter!] + # following_not_in: [_GroupFilter!] + # following_some: _GroupFilter + # following_none: _GroupFilter + # following_single: _GroupFilter + # following_every: _GroupFilter + # followedBy: _GroupFilter + # followedBy_not: _GroupFilter + # followedBy_in: [_GroupFilter!] + # followedBy_not_in: [_GroupFilter!] + # followedBy_some: _GroupFilter + # followedBy_none: _GroupFilter + # followedBy_single: _GroupFilter + # followedBy_every: _GroupFilter +} + +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]! + + # Wolle: + # availableRoles: [UserRole]! + # mutedUsers: [User] + # blockedUsers: [User] + # isLoggedIn: Boolean! + # currentUser: User + # findUsers(query: String!,limit: Int = 10, filter: _GroupFilter): [User]! + # @cypher( + # statement: """ + # CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) + # YIELD node as post, score + # MATCH (user) + # WHERE score >= 0.2 + # AND NOT user.deleted = true AND NOT user.disabled = true + # RETURN user + # LIMIT $limit + # """ + # ) +} + +# Wolle: enum Deletable { +# Post +# Comment +# } + +type Mutation { + CreateGroup( + id: ID + name: String! + slug: String + avatar: ImageInput + about: String + description: String! + groupType: GroupType! + actionRadius: GroupActionRadius! + categoryIds: [ID] + locationName: String + ): # Wolle: add group settings + # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group + + UpdateGroup( + id: ID! + name: String + slug: String + avatar: ImageInput + locationName: String + about: String + description: String + ): # Wolle: + # showShoutsPublicly: Boolean + # sendNotificationEmails: Boolean + # locale: String + Group + + DeleteGroup(id: ID!): Group + + # Wolle: + # muteUser(id: ID!): User + # unmuteUser(id: ID!): User + # blockUser(id: ID!): User + # unblockUser(id: ID!): User + + # Wolle: switchUserRole(role: UserRole!, id: ID!): User +} 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 000000000..edda989f6 --- /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 772dedf6b..aca08df0e 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: UserGroup - 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: UserGroup + locationName: String + about: String + createdAt: String + updatedAt: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter ): [User] availableRoles: [UserGroup]! @@ -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/package.json b/package.json index e23a5f5c7..7756ac8e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social", - "version": "1.0.9", + "version": "1.1.0", "description": "Free and open source software program code available to run social networks.", "author": "ocelot.social Community", "license": "MIT", diff --git a/webapp/.env.template b/webapp/.env.template index 0a4c3405f..9776fcea2 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/maintenance/source/package.json b/webapp/maintenance/source/package.json index 3ee1a5b5c..79ced0031 100644 --- a/webapp/maintenance/source/package.json +++ b/webapp/maintenance/source/package.json @@ -1,6 +1,6 @@ { "name": "@ocelot-social/maintenance", - "version": "1.0.9", + "version": "1.1.0", "description": "Maintenance page for ocelot.social", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community", diff --git a/webapp/package.json b/webapp/package.json index 77455f49f..234149521 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "ocelot-social-webapp", - "version": "1.0.9", + "version": "1.1.0", "description": "ocelot.social Frontend", "repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social", "author": "ocelot.social Community",