feat(webapp): track online status (#8312)

* client

* backend

* tests

* also save awaySince timestamp

* remove console.log
This commit is contained in:
Ulf Gebhardt 2025-04-03 17:19:53 +02:00 committed by GitHub
parent 4f02060fd7
commit 01027e42cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 192 additions and 0 deletions

View File

@ -462,6 +462,7 @@ export default shield(
switchUserRole: isAdmin,
markTeaserAsViewed: allow,
saveCategorySettings: isAuthenticated,
updateOnlineStatus: isAuthenticated,
CreateRoom: isAuthenticated,
CreateMessage: isAuthenticated,
MarkMessagesAsSeen: isAuthenticated,

View File

@ -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',

View File

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

View File

@ -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) => {

View File

@ -0,0 +1,4 @@
enum OnlineStatus {
online
away
}

View File

@ -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!

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const updateOnlineStatus = gql`
mutation ($status: OnlineStatus!) {
updateOnlineStatus(status: $status)
}
`

View File

@ -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: {

View File

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