feat(backend): badges (#8391)

* delete all old badges

* reward/unrewardBadge

* verification Badges

* name all badged accordingly

* more tests, lint

* seed badges

* profileBadge mechanic

* badgesUnusedCount

* seed profileBadges set

* configure profile badge count

* insert badges db:data:badges:default

* seed commands to seed default badges and allow to seed branding data

* copy data migrations when building docker

* typo

* correct data:branding command & document it

* test new functionality

* Update backend/src/db/seed/badges.ts

Co-authored-by: Max <maxharz@gmail.com>

* Update backend/src/db/seed/badges.ts

Co-authored-by: Max <maxharz@gmail.com>

* Update backend/src/db/seed/badges.ts

Co-authored-by: Max <maxharz@gmail.com>

* naming coventions

* final naming fix

lint

fix build

fix badge type in test

renamed badge_ to trophy_

lint fixes

small renameing

fixes

fix users spec

fix webapp queries

fix display

* expose badge description

---------

Co-authored-by: Max <maxharz@gmail.com>
This commit is contained in:
Ulf Gebhardt 2025-04-18 01:08:54 +02:00 committed by GitHub
parent 89b0fa7a51
commit 8cf405c549
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1639 additions and 464 deletions

View File

@ -23,6 +23,7 @@ COPY . .
ONBUILD COPY ./branding/constants/ src/config/tmp
ONBUILD RUN tools/replace-constants.sh
ONBUILD COPY ./branding/email/ src/middleware/helpers/email/
ONBUILD COPY ./branding/data/ src/db/data
ONBUILD RUN yarn install --production=false --frozen-lockfile --non-interactive
ONBUILD RUN yarn run build
ONBUILD RUN mkdir /build

View File

@ -120,6 +120,20 @@ When using `CATEGORIES_ACTIVE=true` you also want to seed the categories with:
yarn db:data:categories
```
### Branding Data
You might need to seed some branding specific data into the database.
To do so, run:
```sh
# in backend with database running (In docker or local)
yarn db:data:branding
# for docker
docker exec backend yarn db:data:branding
```
### Seed Data
For a predefined set of test data you can seed the database with:

View File

View File

@ -18,6 +18,8 @@
"db:reset:withmigrations": "ts-node --require tsconfig-paths/register src/db/reset-with-migrations.ts",
"db:seed": "ts-node --require tsconfig-paths/register src/db/seed.ts",
"db:data:admin": "ts-node --require tsconfig-paths/register src/db/admin.ts",
"db:data:badges": "ts-node --require tsconfig-paths/register src/db/badges.ts",
"db:data:branding": "ts-node --require tsconfig-paths/register src/db/data-production.ts",
"db:data:categories": "ts-node --require tsconfig-paths/register src/db/categories.ts",
"db:migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --store ./src/db/migrate/store.ts",
"db:migrate:create": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --template-file ./src/db/migrate/template.ts --date-format 'yyyymmddHHmmss' create",

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 637 B

After

Width:  |  Height:  |  Size: 637 B

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 654 B

After

Width:  |  Height:  |  Size: 654 B

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,2 @@
// this file is duplicated in `backend/src/constants/badges` and `webapp/constants/badges.js`
export const TROPHY_BADGES_SELECTED_MAX = 9

13
backend/src/db/badges.ts Normal file
View File

@ -0,0 +1,13 @@
import { getNeode } from './neo4j'
import { trophies, verification } from './seed/badges'
// eslint-disable-next-line import/newline-after-import
;(async function () {
const neode = getNeode()
try {
await trophies()
await verification()
} finally {
await neode.close()
}
})()

View File

@ -0,0 +1,17 @@
import { readdir } from 'node:fs/promises'
import path from 'node:path'
const dataFolder = path.join(__dirname, 'data/')
;(async function () {
const files = await readdir(dataFolder)
files.forEach(async (file) => {
if (file.slice(0, -3).endsWith('-branding')) {
const importedModule = await import(path.join(dataFolder, file))
if (!importedModule.default) {
throw new Error('Your data file must export a default function')
}
await importedModule.default()
}
})
})()

View File

View File

@ -0,0 +1,49 @@
import { getDriver } from '@db/neo4j'
export const description = ''
export async function up(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Implement your migration here.
await transaction.run(`
MATCH (badge:Badge)
DETACH DELETE badge
`)
await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}
export async function down(next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// cannot be rolled back
// Implement your migration here.
// await transaction.run(``)
// await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
session.close()
}
}

View File

@ -18,6 +18,7 @@ import createServer from '@src/server'
import Factory from './factories'
import { getNeode, getDriver } from './neo4j'
import { trophies, verification } from './seed/badges'
if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) {
throw new Error(`You cannot seed the database in a non-staging and real production environment!`)
@ -124,32 +125,28 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
await Hamburg.relateTo(Germany, 'isIn')
await Paris.relateTo(France, 'isIn')
// badges
const racoon = await Factory.build('badge', {
id: 'indiegogo_en_racoon',
icon: '/img/badges/indiegogo_en_racoon.svg',
})
const rabbit = await Factory.build('badge', {
id: 'indiegogo_en_rabbit',
icon: '/img/badges/indiegogo_en_rabbit.svg',
})
const wolf = await Factory.build('badge', {
id: 'indiegogo_en_wolf',
icon: '/img/badges/indiegogo_en_wolf.svg',
})
const bear = await Factory.build('badge', {
id: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg',
})
const turtle = await Factory.build('badge', {
id: 'indiegogo_en_turtle',
icon: '/img/badges/indiegogo_en_turtle.svg',
})
const rhino = await Factory.build('badge', {
id: 'indiegogo_en_rhino',
icon: '/img/badges/indiegogo_en_rhino.svg',
})
const {
trophyAirship,
trophyBee,
trophyStarter,
trophyFlower,
trophyPanda,
trophyTiger,
trophyAlienship,
trophyBalloon,
trophyMagicrainbow,
trophySuperfounder,
trophyBigballoon,
trophyLifetree,
trophyRacoon,
trophyRhino,
trophyWolf,
trophyTurtle,
trophyBear,
trophyRabbit,
} = await trophies()
const { verificationAdmin, verificationModerator, verificationDeveloper } = await verification()
// users
const peterLustig = await Factory.build(
'user',
@ -243,14 +240,50 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
await jennyRostock.relateTo(Paris, 'isIn')
await huey.relateTo(Paris, 'isIn')
await peterLustig.relateTo(racoon, 'rewarded')
await peterLustig.relateTo(rhino, 'rewarded')
await peterLustig.relateTo(wolf, 'rewarded')
await bobDerBaumeister.relateTo(racoon, 'rewarded')
await bobDerBaumeister.relateTo(turtle, 'rewarded')
await jennyRostock.relateTo(bear, 'rewarded')
await dagobert.relateTo(rabbit, 'rewarded')
// badges
await peterLustig.relateTo(trophyRacoon, 'rewarded')
await peterLustig.relateTo(trophyRhino, 'rewarded')
await peterLustig.relateTo(trophyWolf, 'rewarded')
await peterLustig.relateTo(trophyAirship, 'rewarded')
await peterLustig.relateTo(verificationAdmin, 'verifies')
await peterLustig.relateTo(trophyRacoon, 'selected', { slot: 0 })
await peterLustig.relateTo(trophyRhino, 'selected', { slot: 1 })
await peterLustig.relateTo(trophyAirship, 'selected', { slot: 5 })
await bobDerBaumeister.relateTo(trophyRacoon, 'rewarded')
await bobDerBaumeister.relateTo(trophyTurtle, 'rewarded')
await bobDerBaumeister.relateTo(trophyBee, 'rewarded')
await bobDerBaumeister.relateTo(verificationModerator, 'verifies')
await bobDerBaumeister.relateTo(trophyRacoon, 'selected', { slot: 1 })
await bobDerBaumeister.relateTo(trophyTurtle, 'selected', { slot: 2 })
await jennyRostock.relateTo(trophyBear, 'rewarded')
await jennyRostock.relateTo(trophyStarter, 'rewarded')
await jennyRostock.relateTo(trophyFlower, 'rewarded')
await jennyRostock.relateTo(trophyBear, 'selected', { slot: 0 })
await jennyRostock.relateTo(trophyStarter, 'selected', { slot: 1 })
await jennyRostock.relateTo(trophyFlower, 'selected', { slot: 2 })
await huey.relateTo(trophyPanda, 'rewarded')
await huey.relateTo(trophyTiger, 'rewarded')
await huey.relateTo(trophyAlienship, 'rewarded')
await huey.relateTo(trophyBalloon, 'rewarded')
await huey.relateTo(trophyMagicrainbow, 'rewarded')
await huey.relateTo(trophySuperfounder, 'rewarded')
await huey.relateTo(verificationDeveloper, 'verifies')
await huey.relateTo(trophyPanda, 'selected', { slot: 0 })
await huey.relateTo(trophyTiger, 'selected', { slot: 1 })
await huey.relateTo(trophyAlienship, 'selected', { slot: 2 })
await dewey.relateTo(trophyBigballoon, 'rewarded')
await dewey.relateTo(trophyLifetree, 'rewarded')
await dewey.relateTo(trophyBigballoon, 'selected', { slot: 7 })
await dewey.relateTo(trophyLifetree, 'selected', { slot: 8 })
await louie.relateTo(trophyRabbit, 'rewarded')
await louie.relateTo(trophyRabbit, 'selected', { slot: 4 })
// Friends
await peterLustig.relateTo(bobDerBaumeister, 'friends')
await peterLustig.relateTo(jennyRostock, 'friends')
await bobDerBaumeister.relateTo(jennyRostock, 'friends')

View File

@ -0,0 +1,185 @@
import Factory from '@db/factories'
export const trophies = async () => {
return {
// Blue Animals
trophyBear: await Factory.build('badge', {
id: 'trophy_bear',
type: 'trophy',
description: 'You earned a Bear',
icon: '/img/badges/trophy_blue_bear.svg',
}),
trophyPanda: await Factory.build('badge', {
id: 'trophy_panda',
type: 'trophy',
description: 'You earned a Panda',
icon: '/img/badges/trophy_blue_panda.svg',
}),
trophyRabbit: await Factory.build('badge', {
id: 'trophy_rabbit',
type: 'trophy',
description: 'You earned a Rabbit',
icon: '/img/badges/trophy_blue_rabbit.svg',
}),
trophyRacoon: await Factory.build('badge', {
id: 'trophy_racoon',
type: 'trophy',
description: 'You earned a Racoon',
icon: '/img/badges/trophy_blue_racoon.svg',
}),
trophyRhino: await Factory.build('badge', {
id: 'trophy_rhino',
type: 'trophy',
description: 'You earned a Rhino',
icon: '/img/badges/trophy_blue_rhino.svg',
}),
trophyTiger: await Factory.build('badge', {
id: 'trophy_tiger',
type: 'trophy',
description: 'You earned a Tiger',
icon: '/img/badges/trophy_blue_tiger.svg',
}),
trophyTurtle: await Factory.build('badge', {
id: 'trophy_turtle',
type: 'trophy',
description: 'You earned a Turtle',
icon: '/img/badges/trophy_blue_turtle.svg',
}),
trophyWhale: await Factory.build('badge', {
id: 'trophy_whale',
type: 'trophy',
description: 'You earned a Whale',
icon: '/img/badges/trophy_blue_whale.svg',
}),
trophyWolf: await Factory.build('badge', {
id: 'trophy_wolf',
type: 'trophy',
description: 'You earned a Wolf',
icon: '/img/badges/trophy_blue_wolf.svg',
}),
// Green Transports
trophyAirship: await Factory.build('badge', {
id: 'trophy_airship',
type: 'trophy',
description: 'You earned an Airship',
icon: '/img/badges/trophy_green_airship.svg',
}),
trophyAlienship: await Factory.build('badge', {
id: 'trophy_alienship',
type: 'trophy',
description: 'You earned an Alienship',
icon: '/img/badges/trophy_green_alienship.svg',
}),
trophyBalloon: await Factory.build('badge', {
id: 'trophy_balloon',
type: 'trophy',
description: 'You earned a Balloon',
icon: '/img/badges/trophy_green_balloon.svg',
}),
trophyBigballoon: await Factory.build('badge', {
id: 'trophy_bigballoon',
type: 'trophy',
description: 'You earned a Big Balloon',
icon: '/img/badges/trophy_green_bigballoon.svg',
}),
trophyCrane: await Factory.build('badge', {
id: 'trophy_crane',
type: 'trophy',
description: 'You earned a Crane',
icon: '/img/badges/trophy_green_crane.svg',
}),
trophyGlider: await Factory.build('badge', {
id: 'trophy_glider',
type: 'trophy',
description: 'You earned a Glider',
icon: '/img/badges/trophy_green_glider.svg',
}),
trophyHelicopter: await Factory.build('badge', {
id: 'trophy_helicopter',
type: 'trophy',
description: 'You earned a Helicopter',
icon: '/img/badges/trophy_green_helicopter.svg',
}),
// Green Animals
trophyBee: await Factory.build('badge', {
id: 'trophy_bee',
type: 'trophy',
description: 'You earned a Bee',
icon: '/img/badges/trophy_green_bee.svg',
}),
trophyButterfly: await Factory.build('badge', {
id: 'trophy_butterfly',
type: 'trophy',
description: 'You earned a Butterfly',
icon: '/img/badges/trophy_green_butterfly.svg',
}),
// Green Plants
trophyFlower: await Factory.build('badge', {
id: 'trophy_flower',
type: 'trophy',
description: 'You earned a Flower',
icon: '/img/badges/trophy_green_flower.svg',
}),
trophyLifetree: await Factory.build('badge', {
id: 'trophy_lifetree',
type: 'trophy',
description: 'You earned the tree of life',
icon: '/img/badges/trophy_green_lifetree.svg',
}),
// Green Misc
trophyDoublerainbow: await Factory.build('badge', {
id: 'trophy_doublerainbow',
type: 'trophy',
description: 'You earned the Double Rainbow',
icon: '/img/badges/trophy_green_doublerainbow.svg',
}),
trophyEndrainbow: await Factory.build('badge', {
id: 'trophy_endrainbow',
type: 'trophy',
description: 'You earned the End of the Rainbow',
icon: '/img/badges/trophy_green_endrainbow.svg',
}),
trophyMagicrainbow: await Factory.build('badge', {
id: 'trophy_magicrainbow',
type: 'trophy',
description: 'You earned the Magic Rainbow',
icon: '/img/badges/trophy_green_magicrainbow.svg',
}),
trophyStarter: await Factory.build('badge', {
id: 'trophy_starter',
type: 'trophy',
description: 'You earned the Starter Badge',
icon: '/img/badges/trophy_green_starter.svg',
}),
trophySuperfounder: await Factory.build('badge', {
id: 'trophy_superfounder',
type: 'trophy',
description: 'You earned the Super Founder Badge',
icon: '/img/badges/trophy_green_superfounder.svg',
}),
}
}
export const verification = async () => {
return {
// Red Role
verificationModerator: await Factory.build('badge', {
id: 'verification_moderator',
type: 'verification',
description: 'You are a Moderator',
icon: '/img/badges/verification_red_moderator.svg',
}),
verificationAdmin: await Factory.build('badge', {
id: 'verification_admin',
type: 'verification',
description: 'You are an Administrator',
icon: '/img/badges/verification_red_admin.svg',
}),
verificationDeveloper: await Factory.build('badge', {
id: 'verification_developer',
type: 'verification',
description: 'You are a Developer',
icon: '/img/badges/verification_red_developer.svg',
}),
}
}

View File

@ -429,10 +429,9 @@ export default shield(
CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,
unreward: isAdmin,
setVerificationBadge: isAdmin,
rewardTrophyBadge: isAdmin,
revokeBadge: isAdmin,
followUser: isAuthenticated,
unfollowUser: isAuthenticated,
shout: isAuthenticated,
@ -469,6 +468,8 @@ export default shield(
toggleObservePost: isAuthenticated,
muteGroup: and(isAuthenticated, isMemberOfGroup),
unmuteGroup: and(isAuthenticated, isMemberOfGroup),
setTrophyBadgeSelected: isAuthenticated,
resetTrophyBadgesSelected: isAuthenticated,
},
User: {
email: or(isMyOwn, isAdmin),

View File

@ -1,7 +1,7 @@
export default {
id: { type: 'string', primary: true, lowercase: true },
status: { type: 'string', valid: ['permanent', 'temporary'] },
type: { type: 'string', valid: ['role', 'crowdfunding'] },
type: { type: 'string', valid: ['verification', 'trophy'] },
icon: { type: 'string', required: true },
description: { type: 'string', required: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
}

View File

@ -52,6 +52,24 @@ export default {
target: 'Badge',
direction: 'in',
},
selected: {
type: 'relationship',
relationship: 'SELECTED',
target: 'Badge',
direction: 'out',
properties: {
slot: {
type: 'int',
required: true,
},
},
},
verifies: {
type: 'relationship',
relationship: 'VERIFIES',
target: 'Badge',
direction: 'in',
},
invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
lastActiveAt: { type: 'string', isoDate: true },
lastOnlineStatus: { type: 'string' },

View File

@ -0,0 +1,667 @@
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import createServer from '@src/server'
const driver = getDriver()
const instance = getNeode()
let authenticatedUser, regularUser, administrator, moderator, badge, verification, query, mutate
describe('Badges', () => {
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
driver.close()
})
beforeEach(async () => {
regularUser = await Factory.build(
'user',
{
id: 'regular-user-id',
role: 'user',
},
{
email: 'user@example.org',
password: '1234',
},
)
moderator = await Factory.build(
'user',
{
id: 'moderator-id',
role: 'moderator',
},
{
email: 'moderator@example.org',
},
)
administrator = await Factory.build(
'user',
{
id: 'admin-id',
role: 'admin',
},
{
email: 'admin@example.org',
},
)
badge = await Factory.build('badge', {
id: 'trophy_rhino',
type: 'trophy',
description: 'You earned a rhino',
icon: '/img/badges/trophy_blue_rhino.svg',
})
verification = await Factory.build('badge', {
id: 'verification_moderator',
type: 'verification',
description: 'You are a moderator',
icon: '/img/badges/verification_red_moderator.svg',
})
})
// 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()
})
describe('setVerificationBadge', () => {
const variables = {
badgeId: 'verification_moderator',
userId: 'regular-user-id',
}
const setVerificationBadgeMutation = gql`
mutation ($badgeId: ID!, $userId: ID!) {
setVerificationBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
mutate({ mutation: setVerificationBadgeMutation, variables }),
).resolves.toMatchObject({
data: { setVerificationBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated as moderator', () => {
beforeEach(async () => {
authenticatedUser = moderator.toJson()
})
describe('rewards badge to user', () => {
it('throws authorization error', async () => {
await expect(
mutate({ mutation: setVerificationBadgeMutation, variables }),
).resolves.toMatchObject({
data: { setVerificationBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
})
describe('authenticated as admin', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
describe('badge for id does not exist', () => {
it('rejects with an informative error message', async () => {
await expect(
mutate({
mutation: setVerificationBadgeMutation,
variables: { userId: 'regular-user-id', badgeId: 'non-existent-badge-id' },
}),
).resolves.toMatchObject({
data: { setVerificationBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
describe('non-existent user', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: setVerificationBadgeMutation,
variables: { userId: 'non-existent-user-id', badgeId: 'verification_moderator' },
}),
).resolves.toMatchObject({
data: { setVerificationBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
describe('badge is not a verification badge', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: setVerificationBadgeMutation,
variables: { userId: 'regular-user-id', badgeId: 'trophy_rhino' },
}),
).resolves.toMatchObject({
data: { setVerificationBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
it('rewards a verification badge to the user', async () => {
const expected = {
data: {
setVerificationBadge: {
id: 'regular-user-id',
badgeVerification: { id: 'verification_moderator' },
badgeTrophies: [],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: setVerificationBadgeMutation, variables }),
).resolves.toMatchObject(expected)
})
it('overrides the existing verification if a second verification badge is rewarded to the same user', async () => {
await Factory.build('badge', {
id: 'verification_admin',
type: 'verification',
description: 'You are an admin',
icon: '/img/badges/verification_red_admin.svg',
})
const expected = {
data: {
setVerificationBadge: {
id: 'regular-user-id',
badgeVerification: { id: 'verification_admin' },
badgeTrophies: [],
},
},
errors: undefined,
}
await mutate({
mutation: setVerificationBadgeMutation,
variables: {
userId: 'regular-user-id',
badgeId: 'verification_moderator',
},
})
await expect(
mutate({
mutation: setVerificationBadgeMutation,
variables: {
userId: 'regular-user-id',
badgeId: 'verification_admin',
},
}),
).resolves.toMatchObject(expected)
})
it('rewards the same verification badge as well to another user', async () => {
const expected = {
data: {
setVerificationBadge: {
id: 'regular-user-2-id',
badgeVerification: { id: 'verification_moderator' },
badgeTrophies: [],
},
},
errors: undefined,
}
await Factory.build(
'user',
{
id: 'regular-user-2-id',
},
{
email: 'regular2@email.com',
},
)
await mutate({
mutation: setVerificationBadgeMutation,
variables,
})
await expect(
mutate({
mutation: setVerificationBadgeMutation,
variables: {
userId: 'regular-user-2-id',
badgeId: 'verification_moderator',
},
}),
).resolves.toMatchObject(expected)
})
})
})
describe('rewardTrophyBadge', () => {
const variables = {
badgeId: 'trophy_rhino',
userId: 'regular-user-id',
}
const rewardTrophyBadgeMutation = gql`
mutation ($badgeId: ID!, $userId: ID!) {
rewardTrophyBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated as moderator', () => {
beforeEach(async () => {
authenticatedUser = moderator.toJson()
})
describe('rewards badge to user', () => {
it('throws authorization error', async () => {
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
})
describe('authenticated as admin', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
describe('badge for id does not exist', () => {
it('rejects with an informative error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
variables: { userId: 'regular-user-id', badgeId: 'non-existent-badge-id' },
}),
).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
describe('non-existent user', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
variables: { userId: 'non-existent-user-id', badgeId: 'trophy_rhino' },
}),
).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
describe('badge is a verification Badge', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
variables: { userId: 'regular-user-id', badgeId: 'verification_moderator' },
}),
).resolves.toMatchObject({
data: { rewardTrophyBadge: null },
errors: [
{
message:
'Error: Could not reward badge! Ensure the user and the badge exist and the badge is of the correct type.',
},
],
})
})
})
it('rewards a badge to the user', async () => {
const expected = {
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeVerification: null,
badgeTrophies: [{ id: 'trophy_rhino' }],
},
},
errors: undefined,
}
await expect(
mutate({ mutation: rewardTrophyBadgeMutation, variables }),
).resolves.toMatchObject(expected)
})
it('rewards a second different badge to the same user', async () => {
await Factory.build('badge', {
id: 'trophy_racoon',
type: 'trophy',
description: 'You earned a racoon',
icon: '/img/badges/trophy_blue_racoon.svg',
})
const trophies = [{ id: 'trophy_racoon' }, { id: 'trophy_rhino' }]
const expected = {
data: {
rewardTrophyBadge: {
id: 'regular-user-id',
badgeTrophies: expect.arrayContaining(trophies),
},
},
errors: undefined,
}
await mutate({
mutation: rewardTrophyBadgeMutation,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_rhino',
},
})
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
variables: {
userId: 'regular-user-id',
badgeId: 'trophy_racoon',
},
}),
).resolves.toMatchObject(expected)
})
it('rewards the same badge as well to another user', async () => {
const expected = {
data: {
rewardTrophyBadge: {
id: 'regular-user-2-id',
badgeTrophies: [{ id: 'trophy_rhino' }],
},
},
errors: undefined,
}
await Factory.build(
'user',
{
id: 'regular-user-2-id',
},
{
email: 'regular2@email.com',
},
)
await mutate({
mutation: rewardTrophyBadgeMutation,
variables,
})
await expect(
mutate({
mutation: rewardTrophyBadgeMutation,
variables: {
userId: 'regular-user-2-id',
badgeId: 'trophy_rhino',
},
}),
).resolves.toMatchObject(expected)
})
it('creates no duplicate reward relationships', async () => {
await mutate({
mutation: rewardTrophyBadgeMutation,
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' }] }] },
errors: undefined,
}
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})
})
describe('revokeBadge', () => {
const variables = {
badgeId: 'trophy_rhino',
userId: 'regular-user-id',
}
beforeEach(async () => {
await regularUser.relateTo(badge, 'rewarded')
await regularUser.relateTo(verification, 'verifies')
})
const revokeBadgeMutation = gql`
mutation ($badgeId: ID!, $userId: ID!) {
revokeBadge(badgeId: $badgeId, userId: $userId) {
id
badgeVerification {
id
}
badgeTrophies {
id
}
}
}
`
describe('check test setup', () => {
it('user has one badge', async () => {
authenticatedUser = regularUser.toJson()
const userQuery = gql`
{
User(id: "regular-user-id") {
badgeTrophiesCount
badgeTrophies {
id
}
}
}
`
const expected = {
data: { User: [{ badgeTrophiesCount: 1, badgeTrophies: [{ id: 'trophy_rhino' }] }] },
errors: undefined,
}
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: revokeBadgeMutation, variables })).resolves.toMatchObject({
data: { revokeBadge: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
describe('removes badge from user', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: revokeBadgeMutation, variables })).resolves.toMatchObject(
{
data: { revokeBadge: null },
errors: [{ message: 'Not Authorized!' }],
},
)
})
})
})
describe('authenticated admin', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
it('removes a badge from user', async () => {
await expect(mutate({ mutation: revokeBadgeMutation, variables })).resolves.toMatchObject({
data: {
revokeBadge: {
id: 'regular-user-id',
badgeVerification: { id: 'verification_moderator' },
badgeTrophies: [],
},
},
errors: undefined,
})
})
it('does not crash when revoking multiple times', async () => {
await mutate({ mutation: revokeBadgeMutation, variables })
await expect(mutate({ mutation: revokeBadgeMutation, variables })).resolves.toMatchObject({
data: {
revokeBadge: {
id: 'regular-user-id',
badgeVerification: { id: 'verification_moderator' },
badgeTrophies: [],
},
},
errors: undefined,
})
})
it('removes a verification from user', async () => {
await expect(
mutate({
mutation: revokeBadgeMutation,
variables: {
badgeId: 'verification_moderator',
userId: 'regular-user-id',
},
}),
).resolves.toMatchObject({
data: {
revokeBadge: {
id: 'regular-user-id',
badgeVerification: null,
badgeTrophies: [{ id: 'trophy_rhino' }],
},
},
errors: undefined,
})
})
it('does not crash when removing verification multiple times', async () => {
await mutate({
mutation: revokeBadgeMutation,
variables: {
badgeId: 'verification_moderator',
userId: 'regular-user-id',
},
})
await expect(
mutate({
mutation: revokeBadgeMutation,
variables: {
badgeId: 'verification_moderator',
userId: 'regular-user-id',
},
}),
).resolves.toMatchObject({
data: {
revokeBadge: {
id: 'regular-user-id',
badgeVerification: null,
badgeTrophies: [{ id: 'trophy_rhino' }],
},
},
errors: undefined,
})
})
})
})
})

View File

@ -2,8 +2,119 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Query: {
Badge: async (object, args, context, resolveInfo) => {
return neo4jgraphql(object, args, context, resolveInfo)
Badge: async (object, args, context, resolveInfo) =>
neo4jgraphql(object, args, context, resolveInfo),
},
Mutation: {
setVerificationBadge: async (_object, args, 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: 'verification'}), (user:User {id: $userId})
OPTIONAL MATCH (:Badge {type: 'verification'})-[verify:VERIFIES]->(user)
DELETE verify
MERGE (badge)-[relation:VERIFIES {by: $currentUserId}]->(user)
RETURN relation, user {.*}
`,
{
badgeId,
userId,
currentUserId,
},
)
return {
relation: response.records.map((record) => record.get('relation'))[0],
user: response.records.map((record) => record.get('user'))[0],
}
})
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.',
)
}
return user
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
rewardTrophyBadge: async (_object, args, 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,
},
)
return {
relation: response.records.map((record) => record.get('relation'))[0],
user: response.records.map((record) => record.get('user'))[0],
}
})
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.',
)
}
return user
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
revokeBadge: async (_object, args, context, _resolveInfo) => {
const { badgeId, userId } = args
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const response = await transaction.run(
`
MATCH (user:User {id: $userId})
OPTIONAL MATCH (badge:Badge {id: $badgeId})-[relation:REWARDED|VERIFIES]->(user)
DELETE relation
RETURN user {.*}
`,
{
badgeId,
userId,
},
)
return response.records.map((record) => record.get('user'))[0]
})
try {
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
}

View File

@ -1,352 +0,0 @@
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import createServer from '@src/server'
const driver = getDriver()
const instance = getNeode()
let authenticatedUser, regularUser, administrator, moderator, badge, query, mutate
describe('rewards', () => {
const variables = {
from: 'indiegogo_en_rhino',
to: 'regular-user-id',
}
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
driver.close()
})
beforeEach(async () => {
regularUser = await Factory.build(
'user',
{
id: 'regular-user-id',
role: 'user',
},
{
email: 'user@example.org',
password: '1234',
},
)
moderator = await Factory.build(
'user',
{
id: 'moderator-id',
role: 'moderator',
},
{
email: 'moderator@example.org',
},
)
administrator = await Factory.build(
'user',
{
id: 'admin-id',
role: 'admin',
},
{
email: 'admin@example.org',
},
)
badge = await Factory.build('badge', {
id: 'indiegogo_en_rhino',
type: 'crowdfunding',
status: 'permanent',
icon: '/img/badges/indiegogo_en_rhino.svg',
})
})
// 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()
})
describe('reward', () => {
const rewardMutation = gql`
mutation ($from: ID!, $to: ID!) {
reward(badgeKey: $from, userId: $to) {
id
badges {
id
}
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: rewardMutation, variables })).resolves.toMatchObject({
data: { reward: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated admin', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
describe('badge for id does not exist', () => {
it('rejects with an informative error message', async () => {
await expect(
mutate({
mutation: rewardMutation,
variables: { to: 'regular-user-id', from: 'non-existent-badge-id' },
}),
).resolves.toMatchObject({
data: { reward: null },
errors: [{ message: "Couldn't find a badge with that id" }],
})
})
})
describe('non-existent user', () => {
it('rejects with a telling error message', async () => {
await expect(
mutate({
mutation: rewardMutation,
variables: { to: 'non-existent-user-id', from: 'indiegogo_en_rhino' },
}),
).resolves.toMatchObject({
data: { reward: null },
errors: [{ message: "Couldn't find a user with that id" }],
})
})
})
it('rewards a badge to user', async () => {
const expected = {
data: {
reward: {
id: 'regular-user-id',
badges: [{ id: 'indiegogo_en_rhino' }],
},
},
errors: undefined,
}
await expect(mutate({ mutation: rewardMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('rewards a second different badge to same user', async () => {
await Factory.build('badge', {
id: 'indiegogo_en_racoon',
icon: '/img/badges/indiegogo_en_racoon.svg',
})
const badges = [{ id: 'indiegogo_en_racoon' }, { id: 'indiegogo_en_rhino' }]
const expected = {
data: {
reward: {
id: 'regular-user-id',
badges: expect.arrayContaining(badges),
},
},
errors: undefined,
}
await mutate({
mutation: rewardMutation,
variables: {
to: 'regular-user-id',
from: 'indiegogo_en_rhino',
},
})
await expect(
mutate({
mutation: rewardMutation,
variables: {
to: 'regular-user-id',
from: 'indiegogo_en_racoon',
},
}),
).resolves.toMatchObject(expected)
})
it('rewards the same badge as well to another user', async () => {
const expected = {
data: {
reward: {
id: 'regular-user-2-id',
badges: [{ id: 'indiegogo_en_rhino' }],
},
},
errors: undefined,
}
await Factory.build(
'user',
{
id: 'regular-user-2-id',
},
{
email: 'regular2@email.com',
},
)
await mutate({
mutation: rewardMutation,
variables,
})
await expect(
mutate({
mutation: rewardMutation,
variables: {
to: 'regular-user-2-id',
from: 'indiegogo_en_rhino',
},
}),
).resolves.toMatchObject(expected)
})
it('creates no duplicate reward relationships', async () => {
await mutate({
mutation: rewardMutation,
variables,
})
await mutate({
mutation: rewardMutation,
variables,
})
const userQuery = gql`
{
User(id: "regular-user-id") {
badgesCount
badges {
id
}
}
}
`
const expected = {
data: { User: [{ badgesCount: 1, badges: [{ id: 'indiegogo_en_rhino' }] }] },
errors: undefined,
}
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})
describe('authenticated moderator', () => {
beforeEach(async () => {
authenticatedUser = moderator.toJson()
})
describe('rewards badge to user', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: rewardMutation, variables })).resolves.toMatchObject({
data: { reward: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
})
})
describe('unreward', () => {
beforeEach(async () => {
await regularUser.relateTo(badge, 'rewarded')
})
const expected = {
data: { unreward: { id: 'regular-user-id', badges: [] } },
errors: undefined,
}
const unrewardMutation = gql`
mutation ($from: ID!, $to: ID!) {
unreward(badgeKey: $from, userId: $to) {
id
badges {
id
}
}
}
`
describe('check test setup', () => {
it('user has one badge', async () => {
authenticatedUser = regularUser.toJson()
const userQuery = gql`
{
User(id: "regular-user-id") {
badgesCount
badges {
id
}
}
}
`
const expected = {
data: { User: [{ badgesCount: 1, badges: [{ id: 'indiegogo_en_rhino' }] }] },
errors: undefined,
}
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject({
data: { unreward: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('authenticated admin', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
it('removes a badge from user', async () => {
await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('does not crash when unrewarding multiple times', async () => {
await mutate({ mutation: unrewardMutation, variables })
await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('authenticated moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
describe('removes bage from user', () => {
it('throws authorization error', async () => {
await expect(mutate({ mutation: unrewardMutation, variables })).resolves.toMatchObject({
data: { unreward: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
})
})
})

View File

@ -1,47 +0,0 @@
import { UserInputError } from 'apollo-server'
import { getNeode } from '@db/neo4j'
const neode = getNeode()
const getUserAndBadge = async ({ badgeKey, userId }) => {
const user = await neode.first('User', 'id', userId)
const badge = await neode.first('Badge', 'id', badgeKey)
if (!user) throw new UserInputError("Couldn't find a user with that id")
if (!badge) throw new UserInputError("Couldn't find a badge with that id")
return { user, badge }
}
export default {
Mutation: {
reward: async (_object, params, context, _resolveInfo) => {
const { user, badge } = await getUserAndBadge(params)
await user.relateTo(badge, 'rewarded')
return user.toJson()
},
unreward: async (_object, params, context, _resolveInfo) => {
const { badgeKey, userId } = params
const { user } = await getUserAndBadge(params)
const session = context.driver.session()
try {
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (badge:Badge {id: $badgeKey})-[reward:REWARDED]->(rewardedUser:User {id: $userId})
DELETE reward
RETURN rewardedUser
`,
{
badgeKey,
userId,
},
)
})
} finally {
session.close()
}
return user.toJson()
},
},
}

View File

@ -70,6 +70,36 @@ const updateOnlineStatus = gql`
}
`
const setTrophyBadgeSelected = gql`
mutation ($slot: Int!, $badgeId: ID!) {
setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) {
badgeTrophiesCount
badgeTrophiesSelected {
id
}
badgeTrophiesUnused {
id
}
badgeTrophiesUnusedCount
}
}
`
const resetTrophyBadgesSelected = gql`
mutation {
resetTrophyBadgesSelected {
badgeTrophiesCount
badgeTrophiesSelected {
id
}
badgeTrophiesUnused {
id
}
badgeTrophiesUnusedCount
}
}
`
beforeAll(async () => {
await cleanDatabase()
@ -1070,3 +1100,279 @@ describe('updateOnlineStatus', () => {
})
})
})
describe('setTrophyBadgeSelected', () => {
beforeEach(async () => {
user = await Factory.build('user', {
id: 'user',
role: 'user',
})
const badgeBear = await Factory.build('badge', {
id: 'trophy_bear',
type: 'trophy',
description: 'You earned a Bear',
icon: '/img/badges/trophy_blue_bear.svg',
})
const badgePanda = await Factory.build('badge', {
id: 'trophy_panda',
type: 'trophy',
description: 'You earned a Panda',
icon: '/img/badges/trophy_blue_panda.svg',
})
await Factory.build('badge', {
id: 'trophy_rabbit',
type: 'trophy',
description: 'You earned a Rabbit',
icon: '/img/badges/trophy_blue_rabbit.svg',
})
await user.relateTo(badgeBear, 'rewarded')
await user.relateTo(badgePanda, 'rewarded')
})
describe('not authenticated', () => {
beforeEach(async () => {
authenticatedUser = undefined
})
it('throws an error', async () => {
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_bear' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
it('throws Error when slot is out of bound', async () => {
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: -1, badgeId: 'trophy_bear' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Invalid slot! There is only 9 badge-slots to fill',
}),
],
}),
)
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 9, badgeId: 'trophy_bear' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Invalid slot! There is only 9 badge-slots to fill',
}),
],
}),
)
})
it('throws Error when badge was not rewarded to user', async () => {
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_rabbit' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Error: You cannot set badges not rewarded to you.',
}),
],
}),
)
})
it('throws Error when badge is unknown', async () => {
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_unknown' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Error: You cannot set badges not rewarded to you.',
}),
],
}),
)
})
it('returns the user with badges set on slots', async () => {
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_bear' },
}),
).resolves.toEqual(
expect.objectContaining({
data: {
setTrophyBadgeSelected: {
badgeTrophiesCount: 2,
badgeTrophiesSelected: [
{
id: 'trophy_bear',
},
null,
null,
null,
null,
null,
null,
null,
null,
],
badgeTrophiesUnused: [
{
id: 'trophy_panda',
},
],
badgeTrophiesUnusedCount: 1,
},
},
}),
)
await expect(
mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 5, badgeId: 'trophy_panda' },
}),
).resolves.toEqual(
expect.objectContaining({
data: {
setTrophyBadgeSelected: {
badgeTrophiesCount: 2,
badgeTrophiesSelected: [
{
id: 'trophy_bear',
},
null,
null,
null,
null,
{
id: 'trophy_panda',
},
null,
null,
null,
],
badgeTrophiesUnused: [],
badgeTrophiesUnusedCount: 0,
},
},
}),
)
})
})
})
describe('resetTrophyBadgesSelected', () => {
beforeEach(async () => {
user = await Factory.build('user', {
id: 'user',
role: 'user',
})
const badgeBear = await Factory.build('badge', {
id: 'trophy_bear',
type: 'trophy',
description: 'You earned a Bear',
icon: '/img/badges/trophy_blue_bear.svg',
})
const badgePanda = await Factory.build('badge', {
id: 'trophy_panda',
type: 'trophy',
description: 'You earned a Panda',
icon: '/img/badges/trophy_blue_panda.svg',
})
await Factory.build('badge', {
id: 'trophy_rabbit',
type: 'trophy',
description: 'You earned a Rabbit',
icon: '/img/badges/trophy_blue_rabbit.svg',
})
await user.relateTo(badgeBear, 'rewarded')
await user.relateTo(badgePanda, 'rewarded')
await mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_bear' },
})
await mutate({
mutation: setTrophyBadgeSelected,
variables: { slot: 5, badgeId: 'trophy_panda' },
})
})
describe('not authenticated', () => {
beforeEach(async () => {
authenticatedUser = undefined
})
it('throws an error', async () => {
await expect(mutate({ mutation: resetTrophyBadgesSelected })).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
it('returns the user with no profile badges badges set', async () => {
await expect(mutate({ mutation: resetTrophyBadgesSelected })).resolves.toEqual(
expect.objectContaining({
data: {
resetTrophyBadgesSelected: {
badgeTrophiesCount: 2,
badgeTrophiesSelected: [null, null, null, null, null, null, null, null, null],
badgeTrophiesUnused: [
{
id: 'trophy_panda',
},
{
id: 'trophy_bear',
},
],
badgeTrophiesUnusedCount: 2,
},
},
}),
)
})
})
})

View File

@ -1,6 +1,7 @@
import { UserInputError, ForbiddenError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { getNeode } from '@db/neo4j'
import log from './helpers/databaseLogger'
@ -381,6 +382,73 @@ export default {
return true
},
setTrophyBadgeSelected: async (_object, args, context, _resolveInfo) => {
const { slot, badgeId } = args
const {
user: { id: userId },
} = context
if (slot >= TROPHY_BADGES_SELECTED_MAX || slot < 0) {
throw new Error(
`Invalid slot! There is only ${TROPHY_BADGES_SELECTED_MAX} badge-slots to fill`,
)
}
const session = context.driver.session()
const query = session.writeTransaction(async (transaction) => {
const result = await transaction.run(
`
MATCH (user:User {id: $userId})<-[:REWARDED]-(badge:Badge {id: $badgeId})
OPTIONAL MATCH (user)-[badgeRelation:SELECTED]->(badge)
OPTIONAL MATCH (user)-[slotRelation:SELECTED{slot: $slot}]->(:Badge)
DELETE badgeRelation, slotRelation
MERGE (user)-[:SELECTED{slot: toInteger($slot)}]->(badge)
RETURN user {.*}
`,
{ userId, badgeId, slot },
)
return result.records.map((record) => record.get('user'))[0]
})
try {
const user = await query
if (!user) {
throw new Error('You cannot set badges not rewarded to you.')
}
return user
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
resetTrophyBadgesSelected: async (_object, _args, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
const query = session.writeTransaction(async (transaction) => {
const result = await transaction.run(
`
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[relation:SELECTED]->(:Badge)
DELETE relation
RETURN user {.*}
`,
{ userId },
)
return result.records.map((record) => record.get('user'))[0]
})
try {
return await query
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
},
User: {
emailNotificationSettings: async (parent, params, context, resolveInfo) => {
@ -438,6 +506,87 @@ export default {
},
]
},
badgeTrophiesSelected: async (parent, _params, context, _resolveInfo) => {
const session = context.driver.session()
const query = session.readTransaction(async (transaction) => {
const result = await transaction.run(
`
MATCH (user:User {id: $parent.id})-[relation:SELECTED]->(badge:Badge)
WITH relation, badge
ORDER BY relation.slot ASC
RETURN relation.slot as slot, badge {.*}
`,
{ parent },
)
return result.records
})
try {
const badgesSelected = await query
const result = Array(TROPHY_BADGES_SELECTED_MAX).fill(null)
badgesSelected.map((record) => {
result[record.get('slot')] = record.get('badge')
return true
})
return result
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
badgeTrophiesUnused: async (_parent, _params, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
const query = session.writeTransaction(async (transaction) => {
const result = await transaction.run(
`
MATCH (user:User {id: $userId})<-[:REWARDED]-(badge:Badge)
WHERE NOT (user)-[:SELECTED]-(badge)
RETURN badge {.*}
`,
{ userId },
)
return result.records.map((record) => record.get('badge'))
})
try {
return await query
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
badgeTrophiesUnusedCount: async (_parent, _params, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
const query = session.writeTransaction(async (transaction) => {
const result = await transaction.run(
`
MATCH (user:User {id: $userId})<-[:REWARDED]-(badge:Badge)
WHERE NOT (user)-[:SELECTED]-(badge)
RETURN toString(COUNT(badge)) as count
`,
{ userId },
)
return result.records.map((record) => record.get('count'))[0]
})
try {
return await query
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
},
...Resolver('User', {
undefinedToNull: [
'actorId',
@ -471,13 +620,14 @@ export default {
'-[:WROTE]->(c:Comment)-[:COMMENTS]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true',
shoutedCount:
'-[:SHOUTED]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true',
badgesCount: '<-[:REWARDED]-(related:Badge)',
badgeTrophiesCount: '<-[:REWARDED]-(related:Badge)',
},
hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
invitedBy: '<-[:INVITED]-(related:User)',
location: '-[:IS_IN]->(related:Location)',
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
badgeVerification: '<-[:VERIFIES]-(related:Badge)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
@ -488,7 +638,7 @@ export default {
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
badgeTrophies: '<-[:REWARDED]-(related:Badge)',
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
},
}),

View File

@ -1,22 +1,17 @@
type Badge {
id: ID!
type: BadgeType!
status: BadgeStatus!
icon: String!
createdAt: String
updatedAt: String
description: String!
rewarded: [User]! @relation(name: "REWARDED", direction: "OUT")
}
enum BadgeStatus {
permanent
temporary
verifies: [User]! @relation(name: "VERIFIES", direction: "OUT")
}
enum BadgeType {
role
crowdfunding
verification
trophy
}
type Query {
@ -24,6 +19,7 @@ type Query {
}
type Mutation {
reward(badgeKey: ID!, userId: ID!): User
unreward(badgeKey: ID!, userId: ID!): User
setVerificationBadge(badgeId: ID!, userId: ID!): User
rewardTrophyBadge(badgeId: ID!, userId: ID!): User
revokeBadge(badgeId: ID!, userId: ID!): User
}

View File

@ -125,8 +125,12 @@ type User {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
badges: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
badgeVerification: Badge @relation(name: "VERIFIES", direction: "IN")
badgeTrophies: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgeTrophiesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
badgeTrophiesSelected: [Badge]! @neo4j_ignore
badgeTrophiesUnused: [Badge]! @neo4j_ignore
badgeTrophiesUnusedCount: Int! @neo4j_ignore
emotions: [EMOTED]
@ -247,4 +251,7 @@ type Mutation {
# Get a JWT Token for the given Email and password
login(email: String!, password: String!): String!
setTrophyBadgeSelected(slot: Int!, badgeId: ID!): User
resetTrophyBadgesSelected: User
}

View File

@ -32,8 +32,8 @@ const comment = {
location: null,
badges: [
{
id: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg',
id: 'trophy_bear',
icon: '/img/badges/trophy_blue_bear.svg',
__typename: 'Badge',
},
],

View File

@ -33,8 +33,8 @@ export const post = {
badges: [
{
id: 'b4',
key: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg',
key: 'trophy_bear',
icon: '/img/badges/trophy_blue_bear.svg',
__typename: 'Badge',
},
],

View File

@ -41,8 +41,8 @@ export const user = {
commentedCount: 3,
badges: [
{
id: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg',
id: 'trophy_bear',
icon: '/img/badges/trophy_blue_bear.svg',
},
],
location: {

View File

@ -0,0 +1,2 @@
// this file is duplicated in `backend/src/constants/badges` and `webapp/constants/badges.js`
export const TROPHY_BADGES_SELECTED_MAX = 9

View File

@ -29,7 +29,7 @@ export default (i18n) => {
commentedCount
followedByCount
followedByCurrentUser
badges {
badgeTrophies {
id
icon
}

View File

@ -26,7 +26,7 @@ export const locationFragment = (lang) => gql`
export const badgesFragment = gql`
fragment badges on User {
badges {
badgeTrophies {
id
icon
}

View File

@ -42,8 +42,8 @@
{{ $t('profile.memberSince') }} {{ user.createdAt | date('MMMM yyyy') }}
</ds-text>
</ds-space>
<ds-space v-if="user.badges && user.badges.length" margin="x-small">
<hc-badges :badges="user.badges" />
<ds-space v-if="user.badgeTrophies && user.badgeTrophies.length" margin="x-small">
<hc-badges :badges="user.badgeTrophies" />
</ds-space>
<ds-flex>
<ds-flex-item>