fix(backend): fix statistics and introduce new values (#8550)

* fix statistics and introduce new values

* fix locales
This commit is contained in:
Ulf Gebhardt 2025-05-12 19:51:15 +02:00 committed by GitHub
parent 4489ae1a89
commit 34c0e5166f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 322 additions and 209 deletions

View File

@ -0,0 +1,29 @@
import gql from 'graphql-tag'
export const statistics = gql`
query statistics {
statistics {
users
usersDeleted
posts
comments
notifications
emails
follows
shouts
invites
chatMessages
chatRooms
tags
locations
groups
inviteCodes
inviteCodesExpired
inviteCodesRedeemed
badgesRewarded
badgesDisplayed
usersVerified
reports
}
}
`

View File

@ -2,52 +2,39 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import createServer from '@src/server'
import { statistics } from '@graphql/queries/statistics'
import createServer, { getContext } from '@src/server'
const database = databaseContext()
let server: ApolloServer
let query, authenticatedUser
const instance = getNeode()
const driver = getDriver()
const statisticsQuery = gql`
query {
statistics {
countUsers
countPosts
countComments
countNotifications
countInvites
countFollows
countShouts
}
}
`
beforeAll(async () => {
await cleanDatabase()
authenticatedUser = undefined
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
// eslint-disable-next-line @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
void server.stop()
void database.driver.close()
database.neode.close()
})
// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543
afterEach(async () => {
await cleanDatabase()
})
@ -63,8 +50,8 @@ describe('statistics', () => {
})
it('returns the count of all users', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countUsers: 6 } },
await expect(query({ query: statistics })).resolves.toMatchObject({
data: { statistics: { users: 6 } },
errors: undefined,
})
})
@ -80,8 +67,8 @@ describe('statistics', () => {
})
it('returns the count of all posts', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countPosts: 3 } },
await expect(query({ query: statistics })).resolves.toMatchObject({
data: { statistics: { posts: 3 } },
errors: undefined,
})
})
@ -97,8 +84,8 @@ describe('statistics', () => {
})
it('returns the count of all comments', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countComments: 2 } },
await expect(query({ query: statistics })).resolves.toMatchObject({
data: { statistics: { comments: 2 } },
errors: undefined,
})
})
@ -116,8 +103,8 @@ describe('statistics', () => {
})
it('returns the count of all follows', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countFollows: 1 } },
await expect(query({ query: statistics })).resolves.toMatchObject({
data: { statistics: { follows: 1 } },
errors: undefined,
})
})
@ -143,8 +130,8 @@ describe('statistics', () => {
})
it('returns the count of all shouts', async () => {
await expect(query({ query: statisticsQuery })).resolves.toMatchObject({
data: { statistics: { countShouts: 2 } },
await expect(query({ query: statistics })).resolves.toMatchObject({
data: { statistics: { shouts: 2 } },
errors: undefined,
})
})

View File

@ -1,48 +1,83 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable security/detect-object-injection */
/* eslint-disable @typescript-eslint/dot-notation */
import { Context } from '@src/server'
export default {
Query: {
statistics: async (_parent, _args, { driver }) => {
const session = driver.session()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const counts: any = {}
try {
const mapping = {
countUsers: 'User',
countPosts: 'Post',
countComments: 'Comment',
countNotifications: 'NOTIFIED',
countEmails: 'EmailAddress',
countFollows: 'FOLLOWS',
countShouts: 'SHOUTED',
}
const statisticsReadTxResultPromise = session.readTransaction(async (transaction) => {
const statisticsTransactionResponse = await transaction.run(
`
CALL apoc.meta.stats() YIELD labels, relTypesCount
RETURN labels, relTypesCount
`,
)
return statisticsTransactionResponse.records.map((record) => {
return {
...record.get('labels'),
...record.get('relTypesCount'),
}
})
})
const [statistics] = await statisticsReadTxResultPromise
Object.keys(mapping).forEach((key) => {
const stat = statistics[mapping[key]]
counts[key] = stat ? stat.toNumber() : 0
})
counts.countInvites = counts.countEmails - counts.countUsers
return counts
} finally {
session.close()
statistics: async (_parent, _args, context: Context) => {
const statistics = {
users: 0,
usersDeleted: 0,
posts: 0,
comments: 0,
notifications: 0,
emails: 0,
follows: 0,
shouts: 0,
invites: 0,
chatMessages: 0,
chatRooms: 0,
tags: 0,
locations: 0,
groups: 0,
inviteCodes: 0,
inviteCodesExpired: 0,
inviteCodesRedeemed: 0,
badgesRewarded: 0,
badgesDisplayed: 0,
usersVerified: 0,
reports: 0,
}
const [metaStats] = (
await context.database.query({
query: `CALL apoc.meta.stats() YIELD labels, relTypesCount
RETURN labels, relTypesCount`,
})
).records.map((record) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return { ...record.get('labels'), ...record.get('relTypesCount') }
})
const deletedUsers = parseInt(
(
await context.database.query({
query: `MATCH (u:User) WHERE NOT (u)-[:PRIMARY_EMAIL]->(:EmailAddress) RETURN toString(count(u)) AS count`,
})
).records[0].get('count') as string,
)
const invalidInviteCodes = parseInt(
(
await context.database.query({
query: `MATCH (i:InviteCode) WHERE NOT i.expiresAt IS NULL OR i.expiresAt >= datetime() RETURN toString(count(i)) AS count`,
})
).records[0].get('count') as string,
)
statistics.users = (metaStats['User']?.toNumber() ?? 0) - deletedUsers
statistics.usersDeleted = deletedUsers
statistics.posts = metaStats['Post']?.toNumber() ?? 0
statistics.comments = metaStats['Comment']?.toNumber() ?? 0
statistics.notifications = metaStats['NOTIFIED']?.toNumber() ?? 0
statistics.emails = metaStats['EmailAddress']?.toNumber() ?? 0
statistics.follows = metaStats['FOLLOWS']?.toNumber() ?? 0
statistics.shouts = metaStats['SHOUTED']?.toNumber() ?? 0
statistics.invites = statistics.emails - statistics.users
statistics.chatMessages = metaStats['Message']?.toNumber() ?? 0
statistics.chatRooms = metaStats['Room']?.toNumber() ?? 0
statistics.tags = metaStats['Tag']?.toNumber() ?? 0
statistics.locations = metaStats['Location']?.toNumber() ?? 0
statistics.groups = metaStats['Group']?.toNumber() ?? 0
statistics.inviteCodes = (metaStats['InviteCode']?.toNumber() ?? 0) - invalidInviteCodes
statistics.inviteCodesExpired = invalidInviteCodes
statistics.inviteCodesRedeemed = metaStats['REDEEMED']?.toNumber() ?? 0
statistics.badgesRewarded = metaStats['REWARDED']?.toNumber() ?? 0
statistics.badgesDisplayed = metaStats['SELECTED']?.toNumber() ?? 0
statistics.usersVerified = metaStats['VERIFIES']?.toNumber() ?? 0
statistics.reports = metaStats['InviteCode']?.toNumber() ?? 0
return statistics
},
},
}

View File

@ -3,12 +3,26 @@ type Query {
}
type Statistics {
countUsers: Int!
countPosts: Int!
countComments: Int!
countNotifications: Int!
countInvites: Int!
countFollows: Int!
countShouts: Int!
users: Int!
usersDeleted: Int!
posts: Int!
comments: Int!
notifications: Int!
emails: Int!
follows: Int!
shouts: Int!
invites: Int!
chatMessages: Int!
chatRooms: Int!
tags: Int!
locations: Int!
groups: Int!
inviteCodes: Int!
inviteCodesExpired: Int!
inviteCodesRedeemed: Int!
badgesRewarded: Int!
badgesDisplayed: Int!
usersVerified: Int!
reports: Int!
}

View File

@ -3,13 +3,27 @@ import gql from 'graphql-tag'
export const Statistics = gql`
query {
statistics {
countUsers
countPosts
countComments
countNotifications
countInvites
countFollows
countShouts
users
usersDeleted
posts
comments
notifications
emails
follows
shouts
invites
chatMessages
chatRooms
tags
locations
groups
inviteCodes
inviteCodesExpired
inviteCodesRedeemed
badgesRewarded
badgesDisplayed
usersVerified
reports
}
}
`

View File

@ -39,16 +39,28 @@
"postCount": "Beiträge"
},
"dashboard": {
"badgesDisplayed": "Badges ausgestellt",
"badgesRewarded": "Badges verteilt",
"chatMessages": "Chat-Nachrichten",
"chatRooms": "Chat-Räume",
"comments": "Kommentare",
"follows": "Folgen",
"emails": "E-Mails",
"follows": "Follows",
"groups": "Gruppen",
"inviteCodes": "Einladungslinks",
"inviteCodesExpired": "Abgelaufene Einladungslinks",
"inviteCodesRedeemed": "Eingelöste Einladungslinks",
"invites": "Einladungen",
"locations": "Orte",
"name": "Startzentrale",
"notifications": "Benachrichtigungen",
"organizations": "Organisationen",
"posts": "Beiträge",
"projects": "Projekte",
"reports": "Gemeldet",
"shouts": "Empfehlungen",
"users": "Nutzer"
"tags": "Tags",
"users": "Nutzer",
"usersDeleted": "Gelöschte Nutzer",
"usersVerified": "Verifizierte Nutzer"
},
"donations": {
"goal": "Monatlich benötigte Spenden",

View File

@ -39,16 +39,28 @@
"postCount": "Posts"
},
"dashboard": {
"badgesDisplayed": "Badges Displayed",
"badgesRewarded": "Badges Rewarded",
"chatMessages": "Chat Messages",
"chatRooms": "Chat Rooms",
"comments": "Comments",
"emails": "E-Mails",
"follows": "Follows",
"groups": "Groups",
"inviteCodes": "Invite Codes",
"inviteCodesExpired": "Expired Invite Codes",
"inviteCodesRedeemed": "Redeemed Invite Codes",
"invites": "Invites",
"locations": "Locations",
"name": "Dashboard",
"notifications": "Notifications",
"organizations": "Organizations",
"posts": "Posts",
"projects": "Projects",
"reports": "Reports",
"shouts": "Shouts",
"users": "Users"
"tags": "Tags",
"users": "Users",
"usersDeleted": "Users deleted",
"usersVerified": "Users verified"
},
"donations": {
"goal": "Monthly donations needed",

View File

@ -39,16 +39,28 @@
"postCount": "Contribuciones"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Comentarios",
"emails": null,
"follows": "Sigue",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Invita",
"locations": null,
"name": "Tablero",
"notifications": "Notificaciones",
"organizations": "Organizaciones",
"posts": "Contribuciones",
"projects": "Proyectos",
"reports": null,
"shouts": "Recomendaciones",
"users": "Usuarios"
"tags": null,
"users": "Usuarios",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": "Donaciones mensuales necesarias",

View File

@ -39,16 +39,28 @@
"postCount": "Postes"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Commentaires",
"emails": null,
"follows": "Suit",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Invitations",
"locations": null,
"name": "Tableau de bord",
"notifications": "Notifications",
"organizations": "Organisations",
"posts": "Postes",
"projects": "Projets",
"reports": null,
"shouts": "Cris",
"users": "Utilisateurs"
"tags": null,
"users": "Utilisateurs",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": "Dons mensuels requis",

View File

@ -39,16 +39,28 @@
"postCount": "Messaggi"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Commenti",
"emails": null,
"follows": "Segue",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Inviti",
"locations": null,
"name": "Cruscotto",
"notifications": "Notifiche",
"organizations": "Organizzazioni",
"posts": "Messaggi",
"projects": "Progetti",
"reports": null,
"shouts": "Gridi",
"users": "Utenti"
"tags": null,
"users": "Utenti",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": "Donazioni mensili necessarie",

View File

@ -39,16 +39,28 @@
"postCount": "Berichten"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Opmerkingen",
"emails": null,
"follows": "Volgt",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Uitnodigingen",
"locations": null,
"name": "Dashboard",
"notifications": "Meldingen",
"organizations": "Organisaties",
"posts": "Berichten",
"projects": "Projecten",
"reports": null,
"shouts": "Shouts",
"users": "Gebruikers"
"tags": null,
"users": "Gebruikers",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": null,

View File

@ -39,16 +39,28 @@
"postCount": "Stanowiska"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Komentarze",
"emails": null,
"follows": "Podąża za",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Zaprasza",
"locations": null,
"name": "Tablica rozdzielcza",
"notifications": "Powiadomienia",
"organizations": "Organizacje",
"posts": "Stanowiska",
"projects": "Projekty",
"reports": null,
"shouts": "Zalecane",
"users": "Użytkownicy"
"tags": null,
"users": "Użytkownicy",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": null,

View File

@ -39,16 +39,28 @@
"postCount": "Postagens"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Comentários",
"emails": null,
"follows": "Segue",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Convites",
"locations": null,
"name": "Painel de controle",
"notifications": "Notificações",
"organizations": "Organizações",
"posts": "Postagens",
"projects": "Projetos",
"reports": null,
"shouts": "Aclamações",
"users": "Usuários"
"tags": null,
"users": "Usuários",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": "Doações mensais necessárias",

View File

@ -39,16 +39,28 @@
"postCount": "Посты"
},
"dashboard": {
"badgesDisplayed": null,
"badgesRewarded": null,
"chatMessages": null,
"chatRooms": null,
"comments": "Комментарии",
"emails": null,
"follows": "Подписки",
"groups": null,
"inviteCodes": null,
"inviteCodesExpired": null,
"inviteCodesRedeemed": null,
"invites": "Приглашения",
"locations": null,
"name": "Панель управления",
"notifications": "Уведомления",
"organizations": "Организации",
"posts": "Посты",
"projects": "Проекты",
"reports": null,
"shouts": "Выкрики",
"users": "Пользователи"
"tags": null,
"users": "Пользователи",
"usersDeleted": null,
"usersVerified": null
},
"donations": {
"goal": "Необходимы ежемесячные пожертвования",

View File

@ -20,100 +20,20 @@
<template v-else-if="data">
<ds-space margin="large">
<ds-flex>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-flex-item
v-for="(value, name, index) in filterStatistics(data.statistics)"
:key="index"
:width="{ base: '100%', sm: '50%', md: '33%' }"
>
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.users')"
:label="$t('admin.dashboard.' + name)"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countUsers" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.posts')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countPosts" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.comments')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countComments" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.notifications')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countNotifications" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.invites')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countInvites" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.follows')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countFollows" />
</client-only>
</ds-number>
</ds-space>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
<ds-space margin="small">
<ds-number
:count="0"
:label="$t('admin.dashboard.shouts')"
size="x-large"
uppercase
>
<client-only slot="count">
<hc-count-to :end-val="data.statistics.countShouts" />
<hc-count-to :end-val="value" />
</client-only>
</ds-number>
</ds-space>
@ -140,5 +60,11 @@ export default {
Statistics,
}
},
methods: {
filterStatistics(data) {
delete data.__typename
return data
},
},
}
</script>