diff --git a/backend/.env.template b/backend/.env.template index 5858a5d1e..dd46846a9 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,3 +28,5 @@ AWS_BUCKET= EMAIL_DEFAULT_SENDER="devops@ocelot.social" EMAIL_SUPPORT="devops@ocelot.social" + +CATEGORIES_ACTIVE=false diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 6ad8c578b..7df780cfc 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -86,6 +86,7 @@ const options = { ORGANIZATION_URL: emails.ORGANIZATION_LINK, PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false, INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } // Check if all required configs are present diff --git a/backend/src/db/graphql/mutations.ts b/backend/src/db/graphql/mutations.ts new file mode 100644 index 000000000..a29cfa112 --- /dev/null +++ b/backend/src/db/graphql/mutations.ts @@ -0,0 +1,29 @@ +import gql from 'graphql-tag' + +export const createGroupMutation = gql` + mutation ( + $id: ID, + $name: String!, + $slug: String, + $about: String, + $categoryIds: [ID] + ) { + CreateGroup( + id: $id + name: $name + slug: $slug + about: $about + categoryIds: $categoryIds + ) { + id + name + slug + about + disabled + deleted + owner { + name + } + } + } +` diff --git a/backend/src/db/migrate/store.js b/backend/src/db/migrate/store.js index 7a8be0b94..78960be6b 100644 --- a/backend/src/db/migrate/store.js +++ b/backend/src/db/migrate/store.js @@ -59,7 +59,7 @@ 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("user_fulltext_search",["User"],["name", "slug"])', 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..7e23cfe0f 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -140,6 +140,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..25c7c21a4 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.title, 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/models/Group.js b/backend/src/models/Group.js index b26f7779c..651c2983e 100644 --- a/backend/src/models/Group.js +++ b/backend/src/models/Group.js @@ -15,7 +15,8 @@ export default { wasSeeded: 'boolean', // Wolle: used or needed? locationName: { type: 'string', allow: [null] }, about: { type: 'string', allow: [null, ''] }, // Wolle: null? - description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content + description: { type: 'string', allow: [null, ''] }, // Wolle: null? HTML with Tiptap, similar to post content, wie bei Posts "content: { type: 'string', disallow: [null], min: 3 },"? + descriptionExcerpt: { type: 'string', allow: [null] }, // Wolle: followedBy: { // type: 'relationship', // relationship: 'FOLLOWS', @@ -25,8 +26,14 @@ export default { // createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, // }, // }, + owner: { + type: 'relationship', + relationship: 'OWNS', + target: 'User', + direction: 'in', + }, // Wolle: correct this way? - members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, + // members: { type: 'relationship', relationship: 'MEMBERS', target: 'User', direction: 'out' }, // Wolle: needed? lastActiveAt: { type: 'string', isoDate: true }, createdAt: { type: 'string', diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js new file mode 100644 index 000000000..0ed5d1356 --- /dev/null +++ b/backend/src/schema/resolvers/groups.js @@ -0,0 +1,224 @@ +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 { + // Wolle: Query: { + // Post: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // params = await maintainPinnedPosts(params) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // findPosts: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // profilePagePosts: async (object, params, context, resolveInfo) => { + // params = await filterForMutedUsers(params, context) + // return neo4jgraphql(object, params, context, resolveInfo) + // }, + // PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + // const { postId, data } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsCountTransactionResponse = await transaction.run( + // ` + // MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + // RETURN COUNT(DISTINCT emoted) as emotionsCount + // `, + // { postId, data }, + // ) + // return emotionsCountTransactionResponse.records.map( + // (record) => record.get('emotionsCount').low, + // ) + // }) + // try { + // const [emotionsCount] = await readTxResultPromise + // return emotionsCount + // } finally { + // session.close() + // } + // }, + // PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + // const { postId } = params + // const session = context.driver.session() + // const readTxResultPromise = session.readTransaction(async (transaction) => { + // const emotionsTransactionResponse = await transaction.run( + // ` + // MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + // RETURN collect(emoted.emotion) as emotion + // `, + // { userId: context.user.id, postId }, + // ) + // return emotionsTransactionResponse.records.map((record) => record.get('emotion')) + // }) + // try { + // const [emotions] = await readTxResultPromise + // return emotions + // } 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 + 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 (group)<-[:OWNS]-(owner) + MERGE (group)<-[:ADMINISTERS]-(owner) + ${categoriesCypher} + RETURN group {.*} + `, + { 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 (e) { + if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('Group with this slug already exists!') + throw new Error(e) + } 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..aba718159 --- /dev/null +++ b/backend/src/schema/resolvers/groups.spec.js @@ -0,0 +1,610 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import { createGroupMutation } from '../../db/graphql/mutations' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' + +const driver = getDriver() +const neode = getNeode() + +// Wolle: 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, + } + }, + }) + // Wolle: 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('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-best-group', + about: 'We will change the world!', + 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: { + // Wolle: id: 'g589', + name: 'The Best Group', + slug: 'the-best-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', + 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/type/Group.gql b/backend/src/schema/types/type/Group.gql index 655a251d6..2dbe3c8c6 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -21,7 +21,7 @@ type Group { id: ID! name: String # title slug: String! - + createdAt: String updatedAt: String deleted: Boolean @@ -41,12 +41,14 @@ type Group { # Wolle: needed? socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") + 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)") + # 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! @@ -207,14 +209,14 @@ type Query { type Mutation { CreateGroup ( - id: ID! - name: String - email: String + id: ID + name: String! slug: String avatar: ImageInput locationName: String about: String description: String + categoryIds: [ID] # Wolle: add group settings # Wolle: # showShoutsPublicly: Boolean @@ -222,10 +224,9 @@ type Mutation { # locale: String ): Group - UpdateUser ( + UpdateGroup ( id: ID! name: String - email: String slug: String avatar: ImageInput locationName: String diff --git a/webapp/.env.template b/webapp/.env.template index 7373255a9..9776fcea2 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -4,3 +4,4 @@ PUBLIC_REGISTRATION=false INVITE_REGISTRATION=true WEBSOCKETS_URI=ws://localhost:3000/api/graphql GRAPHQL_URI=http://localhost:4000/ +CATEGORIES_ACTIVE=false diff --git a/webapp/config/index.js b/webapp/config/index.js index 00df85bac..db030e929 100644 --- a/webapp/config/index.js +++ b/webapp/config/index.js @@ -33,6 +33,7 @@ const options = { // Cookies COOKIE_EXPIRE_TIME: process.env.COOKIE_EXPIRE_TIME || 730, // Two years by default COOKIE_HTTPS_ONLY: process.env.COOKIE_HTTPS_ONLY || process.env.NODE_ENV === 'production', // ensure true in production if not set explicitly + CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, } const CONFIG = {