diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 74ebd1c43..e37b9fed0 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -2,8 +2,58 @@ name: ocelot.social end-to-end test CI on: push jobs: + docker_preparation: + name: Fullstack test preparation + runs-on: ubuntu-latest + outputs: + pr-number: ${{ steps.pr.outputs.number }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Copy env files + run: | + cp webapp/.env.template webapp/.env + cp backend/.env.template backend/.env + + - name: Build docker images + run: | + mkdir /tmp/images + docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ + docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/images/neo4j.tar + docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ + docker save "ocelotsocialnetwork/backend:test" > /tmp/images/backend.tar + docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/ + docker save "ocelotsocialnetwork/webapp:test" > /tmp/images/webapp.tar + + - name: Install cypress requirements + run: | + wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386" + cd backend + yarn install + yarn build + cd .. + yarn install + + - name: Get pr number + id: pr + uses: 8BitJonny/gh-get-current-pr@2.2.0 + + - name: Cache docker images + id: cache + uses: actions/cache/save@v3.3.1 + with: + path: | + /opt/cucumber-json-formatter + /home/runner/.cache/Cypress + /home/runner/work/Ocelot-Social/Ocelot-Social + /tmp/images/ + key: e2e-preparation-cache-pr${{ steps.pr.outputs.number }} + fullstack_tests: name: Fullstack tests + if: success() + needs: docker_preparation runs-on: ubuntu-latest env: jobs: 8 @@ -12,28 +62,27 @@ jobs: # run copies of the current job in parallel job: [1, 2, 3, 4, 5, 6, 7, 8] steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Restore cache + uses: actions/cache/restore@v3.3.1 + id: cache + with: + path: | + /opt/cucumber-json-formatter + /home/runner/.cache/Cypress + /home/runner/work/Ocelot-Social/Ocelot-Social + /tmp/images/ + key: e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }} + fail-on-cache-miss: true - - name: webapp | copy env file - run: cp webapp/.env.template webapp/.env - - - name: backend | copy env file - run: cp backend/.env.template backend/.env - - - name: boot up test system | docker-compose - run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend - - - name: Full stack tests | prepare + - name: Boot up test system | docker-compose run: | - wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386" chmod +x /opt/cucumber-json-formatter sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter - cd backend - yarn install - yarn build - cd .. - yarn install + docker load < /tmp/images/neo4j.tar + docker load < /tmp/images/backend.tar + docker load < /tmp/images/webapp.tar + docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend + sleep 90s - name: Full stack tests | run tests id: e2e-tests @@ -44,17 +93,26 @@ jobs: run: | cd cypress/ node create-cucumber-html-report.js - - - name: End-to-end tests | if tests failed, get pr number - id: pr - if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} - uses: 8BitJonny/gh-get-current-pr@2.2.0 - - name: End-to-end tests | if tests failed, upload report + - name: Full stack tests | if tests failed, upload report id: e2e-report if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} uses: actions/upload-artifact@v3 with: - name: ocelot-e2e-test-report-pr${{ steps.pr.outputs.number }} + name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }} path: /home/runner/work/Ocelot-Social/Ocelot-Social/cypress/reports/cucumber_html_report + cleanup: + name: Cleanup + if: always() + needs: [docker_preparation, fullstack_tests] + runs-on: ubuntu-latest + steps: + - name: Delete cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh extension install actions/gh-actions-cache + set +e + KEY="e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}" + gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm \ No newline at end of file diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index a717ff7a6..7286683dd 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -11,6 +11,8 @@ import { changeGroupMemberRoleMutation, } from '../graphql/groups' import { createPostMutation } from '../graphql/posts' +import { createRoomMutation } from '../graphql/rooms' +import { createMessageMutation } from '../graphql/messages' import { createCommentMutation } from '../graphql/comments' import { categories } from '../constants/categories' @@ -1553,6 +1555,90 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ) await Factory.build('donations') + + // Chat + authenticatedUser = await huey.toJson() + const { data: roomHueyPeter } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await peterLustig.toJson()).id, + }, + }) + + for (let i = 0; i < 30; i++) { + authenticatedUser = await huey.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyPeter?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await peterLustig.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyPeter?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + + authenticatedUser = await huey.toJson() + const { data: roomHueyJenny } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await jennyRostock.toJson()).id, + }, + }) + for (let i = 0; i < 1000; i++) { + authenticatedUser = await huey.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyJenny?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await jennyRostock.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyJenny?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + + for (const user of additionalUsers) { + authenticatedUser = await jennyRostock.toJson() + const { data: room } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await user.toJson()).id, + }, + }) + + for (let i = 0; i < 29; i++) { + authenticatedUser = await jennyRostock.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: room?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: room?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + } + /* eslint-disable-next-line no-console */ console.log('Seeded Data...') await driver.close() diff --git a/backend/src/graphql/messages.ts b/backend/src/graphql/messages.ts index ca5ffb952..fde45083b 100644 --- a/backend/src/graphql/messages.ts +++ b/backend/src/graphql/messages.ts @@ -6,6 +6,10 @@ export const createMessageMutation = () => { CreateMessage(roomId: $roomId, content: $content) { id content + senderId + username + avatar + date saved distributed seen @@ -17,7 +21,7 @@ export const createMessageMutation = () => { export const messageQuery = () => { return gql` query ($roomId: ID!, $first: Int, $offset: Int) { - Message(roomId: $roomId, first: $first, offset: $offset, orderBy: createdAt_desc) { + Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) { _id id indexId diff --git a/backend/src/graphql/rooms.ts b/backend/src/graphql/rooms.ts index 109bf1d55..294b50641 100644 --- a/backend/src/graphql/rooms.ts +++ b/backend/src/graphql/rooms.ts @@ -6,18 +6,9 @@ export const createRoomMutation = () => { CreateRoom(userId: $userId) { id roomId - } - } - ` -} - -export const roomQuery = () => { - return gql` - query { - Room { - id - roomId roomName + lastMessageAt + unreadCount users { _id id @@ -30,3 +21,45 @@ export const roomQuery = () => { } ` } + +export const roomQuery = () => { + return gql` + query Room($first: Int, $offset: Int, $id: ID) { + Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) { + id + roomId + roomName + lastMessageAt + unreadCount + lastMessage { + _id + id + content + senderId + username + avatar + date + saved + distributed + seen + } + users { + _id + id + name + avatar { + url + } + } + } + } + ` +} + +export const unreadRoomsQuery = () => { + return gql` + query { + UnreadRooms + } + ` +} diff --git a/backend/src/middleware/chatMiddleware.ts b/backend/src/middleware/chatMiddleware.ts new file mode 100644 index 000000000..c28d6a70d --- /dev/null +++ b/backend/src/middleware/chatMiddleware.ts @@ -0,0 +1,57 @@ +import { isArray } from 'lodash' + +const setRoomProps = (room) => { + if (room.users) { + room.users.forEach((user) => { + user._id = user.id + }) + } + if (room.lastMessage) { + room.lastMessage._id = room.lastMessage.id + } +} + +const setMessageProps = (message, context) => { + message._id = message.id + if (message.senderId !== context.user.id) { + message.distributed = true + } +} + +const roomProperties = async (resolve, root, args, context, info) => { + const resolved = await resolve(root, args, context, info) + if (resolved) { + if (isArray(resolved)) { + resolved.forEach((room) => { + setRoomProps(room) + }) + } else { + setRoomProps(resolved) + } + } + return resolved +} + +const messageProperties = async (resolve, root, args, context, info) => { + const resolved = await resolve(root, args, context, info) + if (resolved) { + if (isArray(resolved)) { + resolved.forEach((message) => { + setMessageProps(message, context) + }) + } else { + setMessageProps(resolved, context) + } + } + return resolved +} + +export default { + Query: { + Room: roomProperties, + Message: messageProperties, + }, + Mutation: { + CreateRoom: roomProperties, + }, +} diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 813bbe9a7..08c872db7 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -14,6 +14,7 @@ import login from './login/loginMiddleware' import sentry from './sentryMiddleware' import languages from './languages/languages' import userInteractions from './userInteractions' +import chatMiddleware from './chatMiddleware' export default (schema) => { const middlewares = { @@ -31,6 +32,7 @@ export default (schema) => { orderBy, languages, userInteractions, + chatMiddleware, } let order = [ @@ -49,6 +51,7 @@ export default (schema) => { 'softDelete', 'includedFields', 'orderBy', + 'chatMiddleware', ] // add permisions middleware at the first position (unless we're seeding) diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index c07098a3c..f87f4b079 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -408,6 +408,7 @@ export default shield( getInviteCode: isAuthenticated, // and inviteRegistration Room: isAuthenticated, Message: isAuthenticated, + UnreadRooms: isAuthenticated, }, Mutation: { '*': deny, diff --git a/backend/src/schema/resolvers/messages.spec.ts b/backend/src/schema/resolvers/messages.spec.ts index d0f1d7871..1679b0c34 100644 --- a/backend/src/schema/resolvers/messages.spec.ts +++ b/backend/src/schema/resolvers/messages.spec.ts @@ -1,13 +1,15 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' import { getNeode, getDriver } from '../../db/neo4j' -import { createRoomMutation } from '../../graphql/rooms' +import { createRoomMutation, roomQuery } from '../../graphql/rooms' import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages' -import createServer from '../../server' +import createServer, { pubsub } from '../../server' const driver = getDriver() const neode = getNeode() +const pubsubSpy = jest.spyOn(pubsub, 'publish') + let query let mutate let authenticatedUser @@ -22,6 +24,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) @@ -55,6 +60,10 @@ describe('Message', () => { }) describe('create message', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + describe('unauthenticated', () => { it('throws authorization error', async () => { await expect( @@ -77,7 +86,7 @@ describe('Message', () => { }) describe('room does not exist', () => { - it('returns null', async () => { + it('returns null and does not publish subscription', async () => { await expect( mutate({ mutation: createMessageMutation(), @@ -92,6 +101,7 @@ describe('Message', () => { CreateMessage: null, }, }) + expect(pubsubSpy).not.toBeCalled() }) }) @@ -107,7 +117,7 @@ describe('Message', () => { }) describe('user chats in room', () => { - it('returns the message', async () => { + it('returns the message and publishes subscription', async () => { await expect( mutate({ mutation: createMessageMutation(), @@ -122,12 +132,78 @@ describe('Message', () => { CreateMessage: { id: expect.any(String), content: 'Some nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), saved: true, distributed: false, seen: false, }, }, }) + expect(pubsubSpy).toBeCalledWith('ROOM_COUNT_UPDATED', { + roomCountUpdated: '1', + userId: 'other-chatting-user', + }) + }) + + describe('room is updated as well', () => { + it('has last message set', async () => { + const result = await query({ query: roomQuery() }) + await expect(result).toMatchObject({ + errors: undefined, + data: { + Room: [ + expect.objectContaining({ + lastMessageAt: expect.any(String), + unreadCount: 0, + lastMessage: expect.objectContaining({ + _id: result.data.Room[0].lastMessage.id, + id: expect.any(String), + content: 'Some nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: false, + seen: false, + }), + }), + ], + }, + }) + }) + }) + + describe('unread count for other user', () => { + it('has unread count = 1', async () => { + authenticatedUser = await otherChattingUser.toJson() + await expect(query({ query: roomQuery() })).resolves.toMatchObject({ + errors: undefined, + data: { + Room: [ + expect.objectContaining({ + lastMessageAt: expect.any(String), + unreadCount: 1, + lastMessage: expect.objectContaining({ + _id: expect.any(String), + id: expect.any(String), + content: 'Some nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: false, + seen: false, + }), + }), + ], + }, + }) + }) }) }) diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts index a9937aac4..a908f3fd8 100644 --- a/backend/src/schema/resolvers/messages.ts +++ b/backend/src/schema/resolvers/messages.ts @@ -1,5 +1,22 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import Resolver from './helpers/Resolver' +import { getUnreadRoomsCount } from './rooms' +import { pubsub, ROOM_COUNT_UPDATED } from '../../server' + +const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { + return session.writeTransaction(async (transaction) => { + const setDistributedCypher = ` + MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds + SET m.distributed = true + RETURN m { .* } + ` + const setDistributedTxResponse = await transaction.run(setDistributedCypher, { + undistributedMessagesIds, + }) + const messages = await setDistributedTxResponse.records.map((record) => record.get('m')) + return messages + }) +} export default { Query: { @@ -20,33 +37,15 @@ export default { const undistributedMessagesIds = resolved .filter((msg) => !msg.distributed && msg.senderId !== context.user.id) .map((msg) => msg.id) - if (undistributedMessagesIds.length > 0) { - const session = context.driver.session() - const writeTxResultPromise = session.writeTransaction(async (transaction) => { - const setDistributedCypher = ` - MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds - SET m.distributed = true - RETURN m { .* } - ` - const setDistributedTxResponse = await transaction.run(setDistributedCypher, { - undistributedMessagesIds, - }) - const messages = await setDistributedTxResponse.records.map((record) => record.get('m')) - return messages - }) - try { - await writeTxResultPromise - } finally { - session.close() + const session = context.driver.session() + try { + if (undistributedMessagesIds.length > 0) { + await setMessagesAsDistributed(undistributedMessagesIds, session) } - // send subscription to author to updated the messages + } finally { + session.close() } - resolved.forEach((message) => { - message._id = message.id - if (message.senderId !== context.user.id) { - message.distributed = true - } - }) + // send subscription to author to updated the messages } return resolved.reverse() }, @@ -61,8 +60,11 @@ export default { const writeTxResultPromise = session.writeTransaction(async (transaction) => { const createMessageCypher = ` MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId }) + OPTIONAL MATCH (currentUser)-[:AVATAR_IMAGE]->(image:Image) OPTIONAL MATCH (m:Message)-[:INSIDE]->(room) - WITH MAX(m.indexId) as maxIndex, room, currentUser + OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User) + WHERE NOT recipientUser.id = $currentUserId + WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser CREATE (currentUser)-[:CREATED]->(message:Message { createdAt: toString(datetime()), id: apoc.create.uuid(), @@ -72,20 +74,40 @@ export default { distributed: false, seen: false })-[:INSIDE]->(room) - RETURN message { .* } + SET room.lastMessageAt = toString(datetime()) + RETURN message { + .*, + recipientId: recipientUser.id, + senderId: currentUser.id, + username: currentUser.name, + avatar: image.url, + date: message.createdAt + } ` const createMessageTxResponse = await transaction.run(createMessageCypher, { currentUserId, roomId, content, }) + const [message] = await createMessageTxResponse.records.map((record) => record.get('message'), ) + return message }) try { const message = await writeTxResultPromise + if (message) { + const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session) + + // send subscriptions + await pubsub.publish(ROOM_COUNT_UPDATED, { + roomCountUpdated, + userId: message.recipientId, + }) + } + return message } catch (error) { throw new Error(error) diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts index 03c3d4456..ee291a6c9 100644 --- a/backend/src/schema/resolvers/rooms.spec.ts +++ b/backend/src/schema/resolvers/rooms.spec.ts @@ -1,7 +1,8 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' import { getNeode, getDriver } from '../../db/neo4j' -import { createRoomMutation, roomQuery } from '../../graphql/rooms' +import { createRoomMutation, roomQuery, unreadRoomsQuery } from '../../graphql/rooms' +import { createMessageMutation } from '../../graphql/messages' import createServer from '../../server' const driver = getDriver() @@ -21,6 +22,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) @@ -34,6 +38,8 @@ afterAll(async () => { }) describe('Room', () => { + let roomId: string + beforeAll(async () => { ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ Factory.build('user', { @@ -48,6 +54,14 @@ describe('Room', () => { id: 'not-chatting-user', name: 'Not Chatting User', }), + Factory.build('user', { + id: 'second-chatting-user', + name: 'Second Chatting User', + }), + Factory.build('user', { + id: 'third-chatting-user', + name: 'Third Chatting User', + }), ]) }) @@ -68,8 +82,6 @@ describe('Room', () => { }) describe('authenticated', () => { - let roomId: string - beforeAll(async () => { authenticatedUser = await chattingUser.toJson() }) @@ -122,6 +134,26 @@ describe('Room', () => { CreateRoom: { id: expect.any(String), roomId: result.data.CreateRoom.id, + roomName: 'Other Chatting User', + unreadCount: 0, + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'other-chatting-user', + id: 'other-chatting-user', + name: 'Other Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), }, }, }) @@ -219,6 +251,7 @@ describe('Room', () => { id: expect.any(String), roomId: result.data.Room[0].id, roomName: 'Chatting User', + unreadCount: 0, users: expect.arrayContaining([ { _id: 'chatting-user', @@ -260,4 +293,312 @@ describe('Room', () => { }) }) }) + + describe('unread rooms query', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + }) + }) + }) + + describe('authenticated', () => { + let otherRoomId: string + + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + const result = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'not-chatting-user', + }, + }) + otherRoomId = result.data.CreateRoom.roomId + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: otherRoomId, + content: 'Message to not chatting user', + }, + }) + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId, + content: '1st message to other chatting user', + }, + }) + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId, + content: '2nd message to other chatting user', + }, + }) + authenticatedUser = await otherChattingUser.toJson() + const result2 = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'not-chatting-user', + }, + }) + otherRoomId = result2.data.CreateRoom.roomId + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: otherRoomId, + content: 'Other message to not chatting user', + }, + }) + }) + + describe('as chatting user', () => { + it('has 0 unread rooms', async () => { + authenticatedUser = await chattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + data: { + UnreadRooms: 0, + }, + }) + }) + }) + + describe('as other chatting user', () => { + it('has 1 unread rooms', async () => { + authenticatedUser = await otherChattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + data: { + UnreadRooms: 1, + }, + }) + }) + }) + + describe('as not chatting user', () => { + it('has 2 unread rooms', async () => { + authenticatedUser = await notChattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + data: { + UnreadRooms: 2, + }, + }) + }) + }) + }) + }) + + describe('query several rooms', () => { + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'second-chatting-user', + }, + }) + await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'third-chatting-user', + }, + }) + }) + + it('returns the rooms paginated', async () => { + expect(await query({ query: roomQuery(), variables: { first: 3, offset: 0 } })).toMatchObject( + { + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Third Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'third-chatting-user', + id: 'third-chatting-user', + name: 'Third Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Second Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'second-chatting-user', + id: 'second-chatting-user', + name: 'Second Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Not Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'not-chatting-user', + id: 'not-chatting-user', + name: 'Not Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }, + ) + expect(await query({ query: roomQuery(), variables: { first: 3, offset: 3 } })).toMatchObject( + { + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Other Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'other-chatting-user', + id: 'other-chatting-user', + name: 'Other Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }, + ) + }) + }) + + describe('query single room', () => { + let result: any = null + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + result = await query({ query: roomQuery() }) + }) + describe('as chatter of room', () => { + it('returns the room', async () => { + expect( + await query({ + query: roomQuery(), + variables: { first: 2, offset: 0, id: result.data.Room[0].id }, + }), + ).toMatchObject({ + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Third Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'third-chatting-user', + id: 'third-chatting-user', + name: 'Third Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }) + }) + describe('as not chatter of room', () => { + beforeAll(async () => { + authenticatedUser = await notChattingUser.toJson() + }) + it('returns no room', async () => { + authenticatedUser = await notChattingUser.toJson() + expect( + await query({ + query: roomQuery(), + variables: { first: 2, offset: 0, id: result.data.Room[0].id }, + }), + ).toMatchObject({ + errors: undefined, + data: { + Room: [], + }, + }) + }) + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/rooms.ts b/backend/src/schema/resolvers/rooms.ts index d5015a03b..5e931a446 100644 --- a/backend/src/schema/resolvers/rooms.ts +++ b/backend/src/schema/resolvers/rooms.ts @@ -1,29 +1,50 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import Resolver from './helpers/Resolver' +import { pubsub, ROOM_COUNT_UPDATED } from '../../server' +import { withFilter } from 'graphql-subscriptions' + +export const getUnreadRoomsCount = async (userId, session) => { + return session.readTransaction(async (transaction) => { + const unreadRoomsCypher = ` + MATCH (:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) + WHERE NOT sender.id = $userId AND NOT message.seen + RETURN toString(COUNT(DISTINCT room)) AS count + ` + const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId }) + return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0] + }) +} export default { + Subscription: { + roomCountUpdated: { + subscribe: withFilter( + () => pubsub.asyncIterator(ROOM_COUNT_UPDATED), + (payload, variables) => { + return payload.userId === variables.userId + }, + ), + }, + }, Query: { Room: async (object, params, context, resolveInfo) => { if (!params.filter) params.filter = {} params.filter.users_some = { id: context.user.id, } - const resolved = await neo4jgraphql(object, params, context, resolveInfo) - if (resolved) { - resolved.forEach((room) => { - if (room.users) { - // buggy, you must query the username for this to function correctly - room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name - room.avatar = - room.users.filter((user) => user.id !== context.user.id)[0].avatar?.url || - 'default-avatar' - room.users.forEach((user) => { - user._id = user.id - }) - } - }) + return neo4jgraphql(object, params, context, resolveInfo) + }, + UnreadRooms: async (object, params, context, resolveInfo) => { + const { + user: { id: currentUserId }, + } = context + const session = context.driver.session() + try { + const count = await getUnreadRoomsCount(currentUserId, session) + return count + } finally { + session.close() } - return resolved }, }, Mutation: { @@ -44,7 +65,17 @@ export default { ON CREATE SET room.createdAt = toString(datetime()), room.id = apoc.create.uuid() - RETURN room { .* } + WITH room, user, currentUser + OPTIONAL MATCH (room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) + WHERE NOT sender.id = $currentUserId AND NOT message.seen + WITH room, user, currentUser, message, + user.name AS roomName + RETURN room { + .*, + users: [properties(currentUser), properties(user)], + roomName: roomName, + unreadCount: toString(COUNT(DISTINCT message)) + } ` const createRommTxResponse = await transaction.run(createRoomCypher, { userId, @@ -68,6 +99,7 @@ export default { }, Room: { ...Resolver('Room', { + undefinedToNull: ['lastMessageAt'], hasMany: { users: '<-[:CHATS_IN]-(related:User)', }, diff --git a/backend/src/schema/types/type/Message.gql b/backend/src/schema/types/type/Message.gql index 671c5523a..764181dd9 100644 --- a/backend/src/schema/types/type/Message.gql +++ b/backend/src/schema/types/type/Message.gql @@ -3,8 +3,7 @@ # } enum _MessageOrdering { - createdAt_asc - createdAt_desc + indexId_desc } type Message { diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql index 2ce6556f6..fdce6865b 100644 --- a/backend/src/schema/types/type/Room.gql +++ b/backend/src/schema/types/type/Room.gql @@ -5,6 +5,11 @@ # users_some: _UserFilter # } +# TODO change this to last message date +enum _RoomOrdering { + createdAt_desc +} + type Room { id: ID! createdAt: String @@ -13,8 +18,28 @@ type Room { users: [User]! @relation(name: "CHATS_IN", direction: "IN") roomId: String! @cypher(statement: "RETURN this.id") - roomName: String! ## @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user[0].name") - avatar: String! ## @cypher match not own user in users array + roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name") + avatar: String @cypher(statement: """ + MATCH (this)<-[:CHATS_IN]-(user:User) + WHERE NOT user.id = $cypherParams.currentUserId + OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image) + RETURN image.url + """) + + lastMessageAt: String + + lastMessage: Message @cypher(statement: """ + MATCH (this)<-[:INSIDE]-(message:Message) + WITH message ORDER BY message.indexId DESC LIMIT 1 + RETURN message + """) + + unreadCount: Int @cypher(statement: """ + MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User) + WHERE NOT user.id = $cypherParams.currentUserId + AND NOT message.seen + RETURN count(message) + """) } type Mutation { @@ -24,5 +49,13 @@ type Mutation { } type Query { - Room: [Room] + Room( + id: ID + orderBy: [_RoomOrdering] + ): [Room] + UnreadRooms: Int +} + +type Subscription { + roomCountUpdated(userId: ID!): Int } diff --git a/backend/src/server.ts b/backend/src/server.ts index b4d63c007..feceeb9eb 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,6 +14,8 @@ import bodyParser from 'body-parser' import { graphqlUploadExpress } from 'graphql-upload' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' +// export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' +export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED' const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG let prodPubsub, devPubsub const options = { diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index 95bf5da95..43994ef5d 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -13,13 +13,15 @@ :messages-loaded="messagesLoaded" :rooms="JSON.stringify(rooms)" :room-actions="JSON.stringify(roomActions)" - :rooms-loaded="true" + :rooms-loaded="roomsLoaded" + :loading-rooms="loadingRooms" show-files="false" show-audio="false" :styles="JSON.stringify(computedChatStyle)" :show-footer="true" @send-message="sendMessage($event.detail[0])" @fetch-messages="fetchMessages($event.detail[0])" + @fetch-more-rooms="fetchRooms" :responsive-breakpoint="responsiveBreakpoint" :single-room="singleRoom" show-reaction-emojis="false" @@ -58,10 +60,10 @@