diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 1fc84b665..a38610efd 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -462,6 +462,7 @@ export default shield( switchUserRole: isAdmin, markTeaserAsViewed: allow, saveCategorySettings: isAuthenticated, + updateOnlineStatus: isAuthenticated, CreateRoom: isAuthenticated, CreateMessage: isAuthenticated, MarkMessagesAsSeen: isAuthenticated, diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index b8d024216..9b828e27e 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -54,6 +54,8 @@ export default { }, invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' }, lastActiveAt: { type: 'string', isoDate: true }, + lastOnlineStatus: { type: 'string' }, + awaySince: { type: 'string', isoDate: true }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, updatedAt: { type: 'string', diff --git a/backend/src/schema/resolvers/users.spec.ts b/backend/src/schema/resolvers/users.spec.ts index bc976fb24..09f98ad53 100644 --- a/backend/src/schema/resolvers/users.spec.ts +++ b/backend/src/schema/resolvers/users.spec.ts @@ -63,6 +63,12 @@ const saveCategorySettings = gql` } ` +const updateOnlineStatus = gql` + mutation ($status: OnlineStatus!) { + updateOnlineStatus(status: $status) + } +` + beforeAll(async () => { await cleanDatabase() @@ -722,3 +728,114 @@ describe('save category settings', () => { }) }) }) + +describe('updateOnlineStatus', () => { + beforeEach(async () => { + user = await Factory.build('user', { + id: 'user', + role: 'user', + }) + variables = { + status: 'online', + } + }) + + describe('not authenticated', () => { + beforeEach(async () => { + authenticatedUser = undefined + }) + + it('throws an error', async () => { + await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [ + expect.objectContaining({ + message: 'Not Authorized!', + }), + ], + }), + ) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + }) + + describe('set online', () => { + it('returns true and saves the user in the database as online', async () => { + await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual( + expect.objectContaining({ + data: { updateOnlineStatus: true }, + }), + ) + + const cypher = 'MATCH (u:User {id: $id}) RETURN u' + const result = await neode.cypher(cypher, { id: authenticatedUser.id }) + const dbUser = neode.hydrateFirst(result, 'u', neode.model('User')) + await expect(dbUser.toJson()).resolves.toMatchObject({ + lastOnlineStatus: 'online', + }) + await expect(dbUser.toJson()).resolves.not.toMatchObject({ + awaySince: expect.any(String), + }) + }) + }) + + describe('set away', () => { + beforeEach(() => { + variables = { + status: 'away', + } + }) + + it('returns true and saves the user in the database as away', async () => { + await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual( + expect.objectContaining({ + data: { updateOnlineStatus: true }, + }), + ) + + const cypher = 'MATCH (u:User {id: $id}) RETURN u' + const result = await neode.cypher(cypher, { id: authenticatedUser.id }) + const dbUser = neode.hydrateFirst(result, 'u', neode.model('User')) + await expect(dbUser.toJson()).resolves.toMatchObject({ + lastOnlineStatus: 'away', + awaySince: expect.any(String), + }) + }) + + it('stores the timestamp of the first away call', async () => { + await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual( + expect.objectContaining({ + data: { updateOnlineStatus: true }, + }), + ) + + const cypher = 'MATCH (u:User {id: $id}) RETURN u' + const result = await neode.cypher(cypher, { id: authenticatedUser.id }) + const dbUser = neode.hydrateFirst(result, 'u', neode.model('User')) + await expect(dbUser.toJson()).resolves.toMatchObject({ + lastOnlineStatus: 'away', + awaySince: expect.any(String), + }) + + const awaySince = (await dbUser.toJson()).awaySince + + await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual( + expect.objectContaining({ + data: { updateOnlineStatus: true }, + }), + ) + + const result2 = await neode.cypher(cypher, { id: authenticatedUser.id }) + const dbUser2 = neode.hydrateFirst(result2, 'u', neode.model('User')) + await expect(dbUser2.toJson()).resolves.toMatchObject({ + lastOnlineStatus: 'away', + awaySince, + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index 6f79a4ea9..cab0bc8a3 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -314,6 +314,37 @@ export default { session.close() } }, + updateOnlineStatus: async (object, args, context, resolveInfo) => { + const { status } = args + const { + user: { id }, + } = context + + const CYPHER_AWAY = ` + MATCH (user:User {id: $id}) + WITH user, + CASE user.lastOnlineStatus + WHEN 'away' THEN user.awaySince + ELSE toString(datetime()) + END AS awaySince + SET user.awaySince = awaySince + SET user.lastOnlineStatus = $status + ` + const CYPHER_ONLINE = ` + MATCH (user:User {id: $id}) + SET user.awaySince = null + SET user.lastOnlineStatus = $status + ` + + // Last Online Time is saved as `lastActiveAt` + const session = context.driver.session() + await session.writeTransaction((transaction) => { + // return transaction.run(status === 'away' ? CYPHER_AWAY : CYPHER_ONLINE, { id, status }) + return transaction.run(status === 'away' ? CYPHER_AWAY : CYPHER_ONLINE, { id, status }) + }) + + return true + }, }, User: { email: async (parent, params, context, resolveInfo) => { diff --git a/backend/src/schema/types/enum/OnlineStatus.gql b/backend/src/schema/types/enum/OnlineStatus.gql new file mode 100644 index 000000000..7827ddb0b --- /dev/null +++ b/backend/src/schema/types/enum/OnlineStatus.gql @@ -0,0 +1,4 @@ +enum OnlineStatus { + online + away +} diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index f406e4e45..70b10aa42 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -224,6 +224,8 @@ type Mutation { switchUserRole(role: UserRole!, id: ID!): User saveCategorySettings(activeCategories: [String]): Boolean + + updateOnlineStatus(status: OnlineStatus!): Boolean! requestPasswordReset(email: String!): Boolean! resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean! diff --git a/webapp/graphql/updateOnlineStatus.js b/webapp/graphql/updateOnlineStatus.js new file mode 100644 index 000000000..ee39b0667 --- /dev/null +++ b/webapp/graphql/updateOnlineStatus.js @@ -0,0 +1,7 @@ +import gql from 'graphql-tag' + +export const updateOnlineStatus = gql` + mutation ($status: OnlineStatus!) { + updateOnlineStatus(status: $status) + } +` diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 4e82e9330..9adacd4cc 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -130,6 +130,7 @@ export default { { src: '~/plugins/vue-observe-visibility.js', ssr: false }, { src: '~/plugins/v-mapbox.js', mode: 'client' }, { src: '~/plugins/vue-advanced-chat.js', mode: 'client' }, + { src: '~/plugins/onlineStatus.js', mode: 'client' }, ], router: { diff --git a/webapp/plugins/onlineStatus.js b/webapp/plugins/onlineStatus.js new file mode 100644 index 000000000..81252333c --- /dev/null +++ b/webapp/plugins/onlineStatus.js @@ -0,0 +1,27 @@ +import { updateOnlineStatus as updateOnlineStatusMutation } from '~/graphql/updateOnlineStatus.js' + +let _app = null + +const updateOnlineStatus = async () => { + if (!_app.store.getters['auth/isAuthenticated']) { + return + } + + const status = document.hidden ? 'away' : 'online' + + const client = _app.apolloProvider.defaultClient + + await client.mutate({ + mutation: updateOnlineStatusMutation, + variables: { status }, + }) +} + +export default ({ app }) => { + _app = app + if (process.client) { + window.addEventListener('visibilitychange', updateOnlineStatus) + setInterval(updateOnlineStatus, 30000) + updateOnlineStatus() + } +}