From 538f409086ae36dbb1c1850faf78ad72cea3ec5f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 26 Mar 2025 22:16:06 +0100 Subject: [PATCH] feat(backend): observe posts (#8292) * After creating the post, the author of it automatically observes it to get notifications when there are interactions * a user that comments a post, automatically observes that post to get notifications when there are more interactions on that post * mutation that switches the state of the observation of a post on and off --- backend/src/graphql/posts.ts | 2 + .../src/middleware/permissionsMiddleware.ts | 1 + .../src/middleware/slugifyMiddleware.spec.ts | 3 + backend/src/schema/resolvers/comments.ts | 6 + .../src/schema/resolvers/observePosts.spec.ts | 240 ++++++++++++++++++ backend/src/schema/resolvers/posts.spec.ts | 3 + backend/src/schema/resolvers/posts.ts | 36 +++ .../schema/resolvers/postsInGroups.spec.ts | 3 + backend/src/schema/types/type/Post.gql | 9 + webapp/graphql/Fragments.js | 2 + webapp/graphql/PostMutations.js | 8 + 11 files changed, 313 insertions(+) create mode 100644 backend/src/schema/resolvers/observePosts.spec.ts diff --git a/backend/src/graphql/posts.ts b/backend/src/graphql/posts.ts index d1dc3ee45..dcd75a4ff 100644 --- a/backend/src/graphql/posts.ts +++ b/backend/src/graphql/posts.ts @@ -46,6 +46,8 @@ export const createPostMutation = () => { lng lat } + isObservedByMe + observingUsersCount } } ` diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index f87f4b079..1fc84b665 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -465,6 +465,7 @@ export default shield( CreateRoom: isAuthenticated, CreateMessage: isAuthenticated, MarkMessagesAsSeen: isAuthenticated, + toggleObservePost: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/middleware/slugifyMiddleware.spec.ts b/backend/src/middleware/slugifyMiddleware.spec.ts index b0b2371b2..26bb2cb96 100644 --- a/backend/src/middleware/slugifyMiddleware.spec.ts +++ b/backend/src/middleware/slugifyMiddleware.spec.ts @@ -21,6 +21,9 @@ const { server } = createServer({ driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) diff --git a/backend/src/schema/resolvers/comments.ts b/backend/src/schema/resolvers/comments.ts index c6f07245c..394695f3a 100644 --- a/backend/src/schema/resolvers/comments.ts +++ b/backend/src/schema/resolvers/comments.ts @@ -25,6 +25,12 @@ export default { SET comment.createdAt = toString(datetime()) SET comment.updatedAt = toString(datetime()) MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) + WITH post, author, comment + MERGE (post)<-[obs:OBSERVES]-(author) + ON CREATE SET + obs.active = true, + obs.createdAt = toString(datetime()), + obs.updatedAt = toString(datetime()) RETURN comment `, { userId: user.id, postId, params }, diff --git a/backend/src/schema/resolvers/observePosts.spec.ts b/backend/src/schema/resolvers/observePosts.spec.ts new file mode 100644 index 000000000..e88bae08a --- /dev/null +++ b/backend/src/schema/resolvers/observePosts.spec.ts @@ -0,0 +1,240 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory, { cleanDatabase } from '../../db/factories' +import gql from 'graphql-tag' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' + +import { createPostMutation } from '../../graphql/posts' +import CONFIG from '../../config' + +CONFIG.CATEGORIES_ACTIVE = false + +const driver = getDriver() +const neode = getNeode() + +let query +let mutate +let authenticatedUser +let user +let otherUser + +const createCommentMutation = gql` + mutation ($id: ID, $postId: ID!, $content: String!) { + CreateComment(id: $id, postId: $postId, content: $content) { + id + } + } +` + +const postQuery = gql` + query Post($id: ID) { + Post(id: $id) { + isObservedByMe + observingUsersCount + } + } +` + +beforeAll(async () => { + await cleanDatabase() + + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() + driver.close() +}) + +describe('observing posts', () => { + beforeAll(async () => { + user = await Factory.build('user', { + id: 'user', + name: 'User', + about: 'I am a user', + }) + otherUser = await Factory.build('user', { + id: 'other-user', + name: 'Other User', + about: 'I am another user', + }) + authenticatedUser = await user.toJson() + }) + + describe('creating posts', () => { + it('has the author of the post observing the post', async () => { + await expect( + mutate({ + mutation: createPostMutation(), + variables: { + id: 'p2', + title: 'A post the author should observe', + content: 'The author of this post is expected to observe the post', + }, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + isObservedByMe: true, + observingUsersCount: 1, + }, + }, + errors: undefined, + }) + }) + }) + + describe('commenting posts', () => { + beforeAll(async () => { + authenticatedUser = await otherUser.toJson() + }) + + it('has another user NOT observing the post BEFORE commenting it', async () => { + await expect( + query({ + query: postQuery, + variables: { id: 'p2' }, + }), + ).resolves.toMatchObject({ + data: { + Post: [ + { + isObservedByMe: false, + observingUsersCount: 1, + }, + ], + }, + errors: undefined, + }) + }) + + it('has another user observing the post AFTER commenting it', async () => { + await mutate({ + mutation: createCommentMutation, + variables: { + postId: 'p2', + content: 'After commenting the post, I should observe the post automatically', + }, + }) + + await expect( + query({ + query: postQuery, + variables: { id: 'p2' }, + }), + ).resolves.toMatchObject({ + data: { + Post: [ + { + isObservedByMe: true, + observingUsersCount: 2, + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('toggle observe post', () => { + beforeAll(async () => { + authenticatedUser = await otherUser.toJson() + }) + + const toggleObservePostMutation = gql` + mutation ($id: ID!, $value: Boolean!) { + toggleObservePost(id: $id, value: $value) { + isObservedByMe + observingUsersCount + } + } + ` + + describe('switch off observation', () => { + it('does not observe the post anymore', async () => { + await expect( + mutate({ + mutation: toggleObservePostMutation, + variables: { + id: 'p2', + value: false, + }, + }), + ).resolves.toMatchObject({ + data: { + toggleObservePost: { + isObservedByMe: false, + observingUsersCount: 1, + }, + }, + errors: undefined, + }) + }) + }) + + describe('comment the post again', () => { + it('does NOT alter the observation state', async () => { + await mutate({ + mutation: createCommentMutation, + variables: { + postId: 'p2', + content: + 'After commenting the post I do not observe again, I should NOT observe the post', + }, + }) + + await expect( + query({ + query: postQuery, + variables: { id: 'p2' }, + }), + ).resolves.toMatchObject({ + data: { + Post: [ + { + isObservedByMe: false, + observingUsersCount: 1, + }, + ], + }, + errors: undefined, + }) + }) + }) + + describe('switch on observation', () => { + it('does observe the post again', async () => { + await expect( + mutate({ + mutation: toggleObservePostMutation, + variables: { + id: 'p2', + value: true, + }, + }), + ).resolves.toMatchObject({ + data: { + toggleObservePost: { + isObservedByMe: true, + observingUsersCount: 2, + }, + }, + errors: undefined, + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/posts.spec.ts b/backend/src/schema/resolvers/posts.spec.ts index 10da1c43e..d7eb063d2 100644 --- a/backend/src/schema/resolvers/posts.spec.ts +++ b/backend/src/schema/resolvers/posts.spec.ts @@ -28,6 +28,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) diff --git a/backend/src/schema/resolvers/posts.ts b/backend/src/schema/resolvers/posts.ts index 0bd4507b5..b111678c0 100644 --- a/backend/src/schema/resolvers/posts.ts +++ b/backend/src/schema/resolvers/posts.ts @@ -144,6 +144,10 @@ export default { WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) + MERGE (post)<-[obs:OBSERVES]-(author) + SET obs.active = true + SET obs.createdAt = toString(datetime()) + SET obs.updatedAt = toString(datetime()) ${categoriesCypher} ${groupCypher} RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] } @@ -416,6 +420,35 @@ export default { session.close() } }, + toggleObservePost: async (_parent, params, context, _resolveInfo) => { + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const transactionResponse = await transaction.run( + ` + MATCH (post:Post { id: $params.id }) + MATCH (user:User { id: $userId }) + MERGE (user)-[obs:OBSERVES]->(post) + ON CREATE SET + obs.createdAt = toString(datetime()), + obs.updatedAt = toString(datetime()), + obs.active = $params.value + ON MATCH SET + obs.updatedAt = toString(datetime()), + obs.active = $params.value + RETURN post + `, + { userId: context.user.id, params }, + ) + return transactionResponse.records.map((record) => record.get('post').properties) + }) + try { + const [post] = await writeTxResultPromise + post.viewedTeaserCount = post.viewedTeaserCount.low + return post + } finally { + session.close() + } + }, }, Post: { ...Resolver('Post', { @@ -452,12 +485,15 @@ export default { shoutedCount: '<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true', emotionsCount: '<-[related:EMOTED]-(:User)', + observingUsersCount: '<-[related:OBSERVES]-(:User) WHERE related.active = true', }, 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', + isObservedByMe: + 'MATCH (this)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1', }, }), relatedContributions: async (parent, params, context, resolveInfo) => { diff --git a/backend/src/schema/resolvers/postsInGroups.spec.ts b/backend/src/schema/resolvers/postsInGroups.spec.ts index ba9041090..c7fc34ec7 100644 --- a/backend/src/schema/resolvers/postsInGroups.spec.ts +++ b/backend/src/schema/resolvers/postsInGroups.spec.ts @@ -52,6 +52,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 7e6d1d0e7..ddf6e557e 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -186,6 +186,13 @@ type Post { eventStart: String eventEnd: String eventIsOnline: Boolean + + isObservedByMe: Boolean! + @cypher( + statement: "MATCH (this)<-[obs:OBSERVES]-(u:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(u) >= 1" + ) + observingUsersCount: Int! + @cypher(statement: "MATCH (this)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true RETURN COUNT(DISTINCT u)") } input _PostInput { @@ -239,6 +246,8 @@ type Mutation { shout(id: ID!, type: ShoutTypeEnum): Boolean! # Unshout the given Type and ID unshout(id: ID!, type: ShoutTypeEnum): Boolean! + + toggleObservePost(id: ID!, value: Boolean!): Post! } type Query { diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index 4931750b6..da1ed0de3 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -67,6 +67,8 @@ export const postFragment = gql` } pinnedAt pinned + isObservedByMe + observingUsersCount } ` diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 73e9f8ebe..5f29534a3 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -175,5 +175,13 @@ export default () => { } } `, + toggleObservePost: gql` + mutation ($id: ID!, $value: Boolean!) { + toggleObservePost(id: $id, value: $value) { + isObservedByMe + observingUsersCount + } + } + `, } }