feat(backend): autoselect badges when rewarding and the user still have free slots (#8577)

* autoselect badges when rewarding and the suer still have free slots

* improve semantics

---------

Co-authored-by: Hendrik-cpu <62690517+Hendrik-cpu@users.noreply.github.com>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Ulf Gebhardt 2025-05-25 14:43:38 +02:00 committed by GitHub
parent df50e7fe2b
commit 2b0d38fdff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 446 additions and 113 deletions

View File

@ -0,0 +1,20 @@
import gql from 'graphql-tag'
export const rewardTrophyBadge = gql`
mutation rewardTrophyBadge($badgeId: ID!, $userId: ID!) {
rewardTrophyBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
isDefault
}
badgeTrophiesCount
badgeTrophies {
id
}
badgeTrophiesSelected {
id
}
}
}
`

View File

@ -0,0 +1,17 @@
import gql from 'graphql-tag'
export const setTrophyBadgeSelected = gql`
mutation setTrophyBadgeSelected($slot: Int!, $badgeId: ID) {
setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) {
badgeTrophiesCount
badgeTrophiesSelected {
id
isDefault
}
badgeTrophiesUnused {
id
}
badgeTrophiesUnusedCount
}
}
`

View File

@ -5,8 +5,11 @@ import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { rewardTrophyBadge } from '@graphql/queries/rewardTrophyBadge'
import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected'
import createServer, { getContext } from '@src/server'
let regularUser, administrator, moderator, badge, verification
@ -295,27 +298,10 @@ describe('Badges', () => {
userId: 'regular-user-id',
}
const rewardTrophyBadgeMutation = gql`
mutation ($badgeId: ID!, $userId: ID!) {
rewardTrophyBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
isDefault
}
badgeTrophies {
id
}
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
@ -329,9 +315,7 @@ describe('Badges', () => {
describe('rewards badge to user', () => {
it('throws authorization error', async () => {
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
@ -348,7 +332,7 @@ describe('Badges', () => {
it('rejects with an informative error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: { userId: 'regular-user-id', badgeId: 'non-existent-badge-id' },
}),
).resolves.toMatchObject({
@ -356,7 +340,7 @@ describe('Badges', () => {
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
@ -367,7 +351,7 @@ describe('Badges', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: { userId: 'non-existent-user-id', badgeId: 'trophy_rhino' },
}),
).resolves.toMatchObject({
@ -375,7 +359,7 @@ describe('Badges', () => {
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
@ -386,7 +370,7 @@ describe('Badges', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: { userId: 'regular-user-id', badgeId: 'verification_moderator' },
}),
).resolves.toMatchObject({
@ -394,7 +378,7 @@ describe('Badges', () => {
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
@ -402,19 +386,43 @@ describe('Badges', () => {
})
it('rewards a badge to the user', async () => {
const expected = {
await expect(mutate({ mutation: rewardTrophyBadge, variables })).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeVerification: { id: 'default_verification', isDefault: true },
badgeTrophies: [{ id: 'trophy_rhino' }],
badgeTrophiesSelected: [
{ id: 'trophy_rhino' },
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject(expected)
})
})
it('rewards a second different badge to the same user', async () => {
@ -424,44 +432,269 @@ describe('Badges', () => {
description: 'You earned a racoon',
icon: '/img/badges/trophy_blue_racoon.svg',
})
const trophies = [{ id: 'trophy_racoon' }, { id: 'trophy_rhino' }]
const expected = {
await mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_racoon',
},
})
await expect(
mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
}),
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeTrophies: expect.arrayContaining(trophies),
badgeTrophies: expect.arrayContaining([
{ id: 'trophy_racoon' },
{ id: 'trophy_rhino' },
]),
badgeTrophiesSelected: [
{ id: 'trophy_racoon' },
{
id: 'trophy_rhino',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
}
})
})
it('does not select a badge again when already rewarded and unselected by the user', async () => {
await Factory.build('badge', {
id: 'trophy_racoon',
type: 'trophy',
description: 'You earned a racoon',
icon: '/img/badges/trophy_blue_racoon.svg',
})
await mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
})
await mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_racoon',
},
})
authenticatedUser = await regularUser.toJson()
await mutate({
mutation: setTrophyBadgeSelected,
variables: {
slot: 0,
badgeId: null,
},
})
authenticatedUser = await administrator.toJson()
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
}),
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeTrophies: expect.arrayContaining([
{ id: 'trophy_racoon' },
{ id: 'trophy_rhino' },
]),
badgeTrophiesSelected: [
{
id: 'default_trophy',
},
{ id: 'trophy_racoon' },
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
})
})
it('does fill gaps in the selection array when rewarding new badges', async () => {
await Factory.build('badge', {
id: 'trophy_racoon',
type: 'trophy',
description: 'You earned a racoon',
icon: '/img/badges/trophy_blue_racoon.svg',
})
await mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
})
authenticatedUser = await regularUser.toJson()
await mutate({
mutation: setTrophyBadgeSelected,
variables: {
slot: 1,
badgeId: 'trophy_rhino',
},
})
authenticatedUser = await administrator.toJson()
await expect(
mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_racoon',
},
}),
).resolves.toMatchObject(expected)
})
it('rewards the same badge as well to another user', async () => {
const expected = {
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-2-id',
badgeTrophies: [{ id: 'trophy_rhino' }],
id: 'regular-user-id',
badgeTrophies: expect.arrayContaining([
{ id: 'trophy_racoon' },
{ id: 'trophy_rhino' },
]),
badgeTrophiesSelected: [
{ id: 'trophy_racoon' },
{ id: 'trophy_rhino' },
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
})
})
it('does not select badge when maximum selected are already reached', async () => {
for (let i = 0; i < TROPHY_BADGES_SELECTED_MAX; i++) {
await Factory.build('badge', {
id: `trophy_${i}`,
type: 'trophy',
description: `You earned a ${i}`,
icon: `/img/badges/trophy_blue_${i}.svg`,
})
await mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: `trophy_${i}`,
},
})
}
await expect(
mutate({
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
}),
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeTrophies: expect.arrayContaining([
{ id: 'trophy_0' },
{ id: 'trophy_1' },
{ id: 'trophy_2' },
{ id: 'trophy_3' },
{ id: 'trophy_4' },
{ id: 'trophy_5' },
{ id: 'trophy_6' },
{ id: 'trophy_7' },
{ id: 'trophy_8' },
{ id: 'trophy_rhino' },
]),
badgeTrophiesSelected: [
{ id: 'trophy_0' },
{ id: 'trophy_1' },
{ id: 'trophy_2' },
{ id: 'trophy_3' },
{ id: 'trophy_4' },
{ id: 'trophy_5' },
{ id: 'trophy_6' },
{ id: 'trophy_7' },
{ id: 'trophy_8' },
],
},
},
errors: undefined,
})
})
it('rewards the same badge as well to another user', async () => {
await Factory.build(
'user',
{
@ -472,46 +705,102 @@ describe('Badges', () => {
},
)
await mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables,
})
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables: {
userId: 'regular-user-2-id',
badgeId: 'trophy_rhino',
},
}),
).resolves.toMatchObject(expected)
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-2-id',
badgeTrophies: [{ id: 'trophy_rhino' }],
badgeTrophiesSelected: [
{ id: 'trophy_rhino' },
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
})
})
it('creates no duplicate reward relationships', async () => {
await mutate({
mutation: rewardTrophyBadgeMutation,
mutation: rewardTrophyBadge,
variables,
})
await mutate({
mutation: rewardTrophyBadgeMutation,
variables,
})
const userQuery = gql`
{
User(id: "regular-user-id") {
badgeTrophiesCount
badgeTrophies {
id
}
}
}
`
const expected = {
data: { User: [{ badgeTrophiesCount: 1, badgeTrophies: [{ id: 'trophy_rhino' }] }] },
await expect(
mutate({
mutation: rewardTrophyBadge,
variables,
}),
).resolves.toMatchObject({
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeTrophiesCount: 1,
badgeTrophies: [{ id: 'trophy_rhino' }],
badgeTrophiesSelected: [
{ id: 'trophy_rhino' },
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
{
id: 'default_trophy',
},
],
},
},
errors: undefined,
}
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})
})
})

View File

@ -6,6 +6,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { Context } from '@src/server'
export const defaultTrophyBadge = {
id: 'default_trophy',
type: 'trophy',
@ -71,44 +74,63 @@ export default {
}
},
rewardTrophyBadge: async (_object, args, context, _resolveInfo) => {
rewardTrophyBadge: async (_object, args, context: Context, _resolveInfo) => {
const {
user: { id: currentUserId },
} = context
const { badgeId, userId } = args
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const response = await transaction.run(
`
MATCH (badge:Badge {id: $badgeId, type: 'trophy'}), (user:User {id: $userId})
MERGE (badge)-[relation:REWARDED {by: $currentUserId}]->(user)
RETURN relation, user {.*}
`,
{
badgeId,
userId,
currentUserId,
},
)
// Find used slot
const userBadges = (
await context.database.query({
query: `
MATCH (rewardedBadge:Badge)-[rewarded:REWARDED]->(user:User {id: $userId})
OPTIONAL MATCH (rewardedBadge)<-[selected:SELECTED]-(user)
RETURN collect(rewardedBadge {.*}) AS rewardedBadges, collect(toString(selected.slot)) AS usedSlots
`,
variables: { userId },
})
).records.map((record) => {
return {
relation: response.records.map((record) => record.get('relation'))[0],
user: response.records.map((record) => record.get('user'))[0],
rewardedBadges: record.get('rewardedBadges'),
usedSlots: record.get('usedSlots'),
}
})
try {
const { relation, user } = await writeTxResultPromise
if (!relation) {
throw new Error(
'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
)
const { rewardedBadges, usedSlots } = userBadges[0]
let slot
if (
!rewardedBadges.find((item) => item.id === badgeId) && // badge was not rewarded yet
usedSlots.length < TROPHY_BADGES_SELECTED_MAX // there is free slots left
) {
for (slot = 0; slot <= TROPHY_BADGES_SELECTED_MAX; slot++) {
if (!usedSlots.find((item) => parseInt(item) === slot)) {
break
}
}
return user
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
// reward badge and assign slot
const users = (
await context.database.write({
query: `
MATCH (badge:Badge {id: $badgeId, type: 'trophy'}), (user:User {id: $userId})
MERGE (badge)-[:REWARDED {by: $currentUserId}]->(user)
${slot === undefined ? '' : 'MERGE (badge)<-[:SELECTED {slot: $slot}]-(user)'}
RETURN user {.*}
`,
variables: { badgeId, userId, currentUserId, slot },
})
).records.map((record) => record.get('user'))
if (users.length !== 1) {
throw new Error(
'Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
)
}
return users[0]
},
revokeBadge: async (_object, args, context, _resolveInfo) => {

View File

@ -12,6 +12,7 @@ import databaseContext from '@context/database'
import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories'
import User from '@db/models/User'
import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected'
import createServer, { getContext } from '@src/server'
const categoryIds = ['cat9']
@ -77,22 +78,6 @@ const updateOnlineStatus = gql`
}
`
const setTrophyBadgeSelected = gql`
mutation ($slot: Int!, $badgeId: ID) {
setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) {
badgeTrophiesCount
badgeTrophiesSelected {
id
isDefault
}
badgeTrophiesUnused {
id
}
badgeTrophiesUnusedCount
}
}
`
const resetTrophyBadgesSelected = gql`
mutation {
resetTrophyBadgesSelected {