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
This commit is contained in:
Moriz Wahl 2025-03-26 22:16:06 +01:00 committed by GitHub
parent 54c2e5c131
commit 538f409086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 313 additions and 0 deletions

View File

@ -46,6 +46,8 @@ export const createPostMutation = () => {
lng lng
lat lat
} }
isObservedByMe
observingUsersCount
} }
} }
` `

View File

@ -465,6 +465,7 @@ export default shield(
CreateRoom: isAuthenticated, CreateRoom: isAuthenticated,
CreateMessage: isAuthenticated, CreateMessage: isAuthenticated,
MarkMessagesAsSeen: isAuthenticated, MarkMessagesAsSeen: isAuthenticated,
toggleObservePost: isAuthenticated,
}, },
User: { User: {
email: or(isMyOwn, isAdmin), email: or(isMyOwn, isAdmin),

View File

@ -21,6 +21,9 @@ const { server } = createServer({
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })

View File

@ -25,6 +25,12 @@ export default {
SET comment.createdAt = toString(datetime()) SET comment.createdAt = toString(datetime())
SET comment.updatedAt = toString(datetime()) SET comment.updatedAt = toString(datetime())
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) 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 RETURN comment
`, `,
{ userId: user.id, postId, params }, { userId: user.id, postId, params },

View File

@ -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,
})
})
})
})
})

View File

@ -28,6 +28,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })

View File

@ -144,6 +144,10 @@ export default {
WITH post WITH post
MATCH (author:User {id: $userId}) MATCH (author:User {id: $userId})
MERGE (post)<-[:WROTE]-(author) MERGE (post)<-[:WROTE]-(author)
MERGE (post)<-[obs:OBSERVES]-(author)
SET obs.active = true
SET obs.createdAt = toString(datetime())
SET obs.updatedAt = toString(datetime())
${categoriesCypher} ${categoriesCypher}
${groupCypher} ${groupCypher}
RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] } RETURN post {.*, postType: [l IN labels(post) WHERE NOT l = 'Post'] }
@ -416,6 +420,35 @@ export default {
session.close() 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: { Post: {
...Resolver('Post', { ...Resolver('Post', {
@ -452,12 +485,15 @@ export default {
shoutedCount: shoutedCount:
'<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true', '<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true',
emotionsCount: '<-[related:EMOTED]-(:User)', emotionsCount: '<-[related:EMOTED]-(:User)',
observingUsersCount: '<-[related:OBSERVES]-(:User) WHERE related.active = true',
}, },
boolean: { boolean: {
shoutedByCurrentUser: shoutedByCurrentUser:
'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1', 'MATCH(this)<-[:SHOUTED]-(related:User {id: $cypherParams.currentUserId}) RETURN COUNT(related) >= 1',
viewedTeaserByCurrentUser: viewedTeaserByCurrentUser:
'MATCH (this)<-[:VIEWED_TEASER]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1', '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) => { relatedContributions: async (parent, params, context, resolveInfo) => {

View File

@ -52,6 +52,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })

View File

@ -186,6 +186,13 @@ type Post {
eventStart: String eventStart: String
eventEnd: String eventEnd: String
eventIsOnline: Boolean 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 { input _PostInput {
@ -239,6 +246,8 @@ type Mutation {
shout(id: ID!, type: ShoutTypeEnum): Boolean! shout(id: ID!, type: ShoutTypeEnum): Boolean!
# Unshout the given Type and ID # Unshout the given Type and ID
unshout(id: ID!, type: ShoutTypeEnum): Boolean! unshout(id: ID!, type: ShoutTypeEnum): Boolean!
toggleObservePost(id: ID!, value: Boolean!): Post!
} }
type Query { type Query {

View File

@ -67,6 +67,8 @@ export const postFragment = gql`
} }
pinnedAt pinnedAt
pinned pinned
isObservedByMe
observingUsersCount
} }
` `

View File

@ -175,5 +175,13 @@ export default () => {
} }
} }
`, `,
toggleObservePost: gql`
mutation ($id: ID!, $value: Boolean!) {
toggleObservePost(id: $id, value: $value) {
isObservedByMe
observingUsersCount
}
}
`,
} }
} }