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 COPY ./branding/constants/ src/config/tmp
ONBUILD RUN tools/replace-constants.sh ONBUILD RUN tools/replace-constants.sh
ONBUILD COPY ./branding/email/ src/middleware/helpers/email/ 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 install --production=false --frozen-lockfile --non-interactive
ONBUILD RUN yarn run build ONBUILD RUN yarn run build
ONBUILD RUN mkdir /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 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 ### Seed Data
For a predefined set of test data you can seed the database with: 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: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: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: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: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": "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", "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 Factory from './factories'
import { getNeode, getDriver } from './neo4j' import { getNeode, getDriver } from './neo4j'
import { trophies, verification } from './seed/badges'
if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) { if (CONFIG.PRODUCTION && !CONFIG.PRODUCTION_DB_CLEAN_ALLOW) {
throw new Error(`You cannot seed the database in a non-staging and real production environment!`) 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 Hamburg.relateTo(Germany, 'isIn')
await Paris.relateTo(France, 'isIn') await Paris.relateTo(France, 'isIn')
// badges const {
const racoon = await Factory.build('badge', { trophyAirship,
id: 'indiegogo_en_racoon', trophyBee,
icon: '/img/badges/indiegogo_en_racoon.svg', trophyStarter,
}) trophyFlower,
const rabbit = await Factory.build('badge', { trophyPanda,
id: 'indiegogo_en_rabbit', trophyTiger,
icon: '/img/badges/indiegogo_en_rabbit.svg', trophyAlienship,
}) trophyBalloon,
const wolf = await Factory.build('badge', { trophyMagicrainbow,
id: 'indiegogo_en_wolf', trophySuperfounder,
icon: '/img/badges/indiegogo_en_wolf.svg', trophyBigballoon,
}) trophyLifetree,
const bear = await Factory.build('badge', { trophyRacoon,
id: 'indiegogo_en_bear', trophyRhino,
icon: '/img/badges/indiegogo_en_bear.svg', trophyWolf,
}) trophyTurtle,
const turtle = await Factory.build('badge', { trophyBear,
id: 'indiegogo_en_turtle', trophyRabbit,
icon: '/img/badges/indiegogo_en_turtle.svg', } = await trophies()
})
const rhino = await Factory.build('badge', {
id: 'indiegogo_en_rhino',
icon: '/img/badges/indiegogo_en_rhino.svg',
})
const { verificationAdmin, verificationModerator, verificationDeveloper } = await verification()
// users // users
const peterLustig = await Factory.build( const peterLustig = await Factory.build(
'user', 'user',
@ -243,14 +240,50 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
await jennyRostock.relateTo(Paris, 'isIn') await jennyRostock.relateTo(Paris, 'isIn')
await huey.relateTo(Paris, 'isIn') await huey.relateTo(Paris, 'isIn')
await peterLustig.relateTo(racoon, 'rewarded') // badges
await peterLustig.relateTo(rhino, 'rewarded') await peterLustig.relateTo(trophyRacoon, 'rewarded')
await peterLustig.relateTo(wolf, 'rewarded') await peterLustig.relateTo(trophyRhino, 'rewarded')
await bobDerBaumeister.relateTo(racoon, 'rewarded') await peterLustig.relateTo(trophyWolf, 'rewarded')
await bobDerBaumeister.relateTo(turtle, 'rewarded') await peterLustig.relateTo(trophyAirship, 'rewarded')
await jennyRostock.relateTo(bear, 'rewarded') await peterLustig.relateTo(verificationAdmin, 'verifies')
await dagobert.relateTo(rabbit, 'rewarded') 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(bobDerBaumeister, 'friends')
await peterLustig.relateTo(jennyRostock, 'friends') await peterLustig.relateTo(jennyRostock, 'friends')
await bobDerBaumeister.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, CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia, UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia, DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin, setVerificationBadge: isAdmin,
// RemoveBadgeRewarded: isAdmin, rewardTrophyBadge: isAdmin,
reward: isAdmin, revokeBadge: isAdmin,
unreward: isAdmin,
followUser: isAuthenticated, followUser: isAuthenticated,
unfollowUser: isAuthenticated, unfollowUser: isAuthenticated,
shout: isAuthenticated, shout: isAuthenticated,
@ -469,6 +468,8 @@ export default shield(
toggleObservePost: isAuthenticated, toggleObservePost: isAuthenticated,
muteGroup: and(isAuthenticated, isMemberOfGroup), muteGroup: and(isAuthenticated, isMemberOfGroup),
unmuteGroup: and(isAuthenticated, isMemberOfGroup), unmuteGroup: and(isAuthenticated, isMemberOfGroup),
setTrophyBadgeSelected: isAuthenticated,
resetTrophyBadgesSelected: isAuthenticated,
}, },
User: { User: {
email: or(isMyOwn, isAdmin), email: or(isMyOwn, isAdmin),

View File

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

View File

@ -52,6 +52,24 @@ export default {
target: 'Badge', target: 'Badge',
direction: 'in', 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' }, invitedBy: { type: 'relationship', relationship: 'INVITED', target: 'User', direction: 'in' },
lastActiveAt: { type: 'string', isoDate: true }, lastActiveAt: { type: 'string', isoDate: true },
lastOnlineStatus: { type: 'string' }, 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 { export default {
Query: { Query: {
Badge: async (object, args, context, resolveInfo) => { Badge: async (object, args, context, resolveInfo) =>
return neo4jgraphql(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 () => { beforeAll(async () => {
await cleanDatabase() 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 { UserInputError, ForbiddenError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { getNeode } from '@db/neo4j' import { getNeode } from '@db/neo4j'
import log from './helpers/databaseLogger' import log from './helpers/databaseLogger'
@ -381,6 +382,73 @@ export default {
return true 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: { User: {
emailNotificationSettings: async (parent, params, context, resolveInfo) => { 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', { ...Resolver('User', {
undefinedToNull: [ undefinedToNull: [
'actorId', 'actorId',
@ -471,13 +620,14 @@ export default {
'-[:WROTE]->(c:Comment)-[:COMMENTS]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', '-[:WROTE]->(c:Comment)-[:COMMENTS]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true',
shoutedCount: shoutedCount:
'-[:SHOUTED]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true', '-[:SHOUTED]->(related:Post) WHERE NOT related.disabled = true AND NOT related.deleted = true',
badgesCount: '<-[:REWARDED]-(related:Badge)', badgeTrophiesCount: '<-[:REWARDED]-(related:Badge)',
}, },
hasOne: { hasOne: {
avatar: '-[:AVATAR_IMAGE]->(related:Image)', avatar: '-[:AVATAR_IMAGE]->(related:Image)',
invitedBy: '<-[:INVITED]-(related:User)', invitedBy: '<-[:INVITED]-(related:User)',
location: '-[:IS_IN]->(related:Location)', location: '-[:IS_IN]->(related:Location)',
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)', redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
badgeVerification: '<-[:VERIFIES]-(related:Badge)',
}, },
hasMany: { hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)', followedBy: '<-[:FOLLOWS]-(related:User)',
@ -488,7 +638,7 @@ export default {
comments: '-[:WROTE]->(related:Comment)', comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)', shouted: '-[:SHOUTED]->(related:Post)',
categories: '-[:CATEGORIZED]->(related:Category)', categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)', badgeTrophies: '<-[:REWARDED]-(related:Badge)',
inviteCodes: '-[:GENERATED]->(related:InviteCode)', inviteCodes: '-[:GENERATED]->(related:InviteCode)',
}, },
}), }),

View File

@ -1,22 +1,17 @@
type Badge { type Badge {
id: ID! id: ID!
type: BadgeType! type: BadgeType!
status: BadgeStatus!
icon: String! icon: String!
createdAt: String createdAt: String
updatedAt: String description: String!
rewarded: [User]! @relation(name: "REWARDED", direction: "OUT") rewarded: [User]! @relation(name: "REWARDED", direction: "OUT")
} verifies: [User]! @relation(name: "VERIFIES", direction: "OUT")
enum BadgeStatus {
permanent
temporary
} }
enum BadgeType { enum BadgeType {
role verification
crowdfunding trophy
} }
type Query { type Query {
@ -24,6 +19,7 @@ type Query {
} }
type Mutation { type Mutation {
reward(badgeKey: ID!, userId: ID!): User setVerificationBadge(badgeId: ID!, userId: ID!): User
unreward(badgeKey: 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") categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
badges: [Badge]! @relation(name: "REWARDED", direction: "IN") badgeVerification: Badge @relation(name: "VERIFIES", direction: "IN")
badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") 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] emotions: [EMOTED]
@ -247,4 +251,7 @@ type Mutation {
# Get a JWT Token for the given Email and password # Get a JWT Token for the given Email and password
login(email: String!, password: String!): String! login(email: String!, password: String!): String!
setTrophyBadgeSelected(slot: Int!, badgeId: ID!): User
resetTrophyBadgesSelected: User
} }

View File

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

View File

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

View File

@ -41,8 +41,8 @@ export const user = {
commentedCount: 3, commentedCount: 3,
badges: [ badges: [
{ {
id: 'indiegogo_en_bear', id: 'trophy_bear',
icon: '/img/badges/indiegogo_en_bear.svg', icon: '/img/badges/trophy_blue_bear.svg',
}, },
], ],
location: { 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 commentedCount
followedByCount followedByCount
followedByCurrentUser followedByCurrentUser
badges { badgeTrophies {
id id
icon icon
} }

View File

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

View File

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