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>
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
0
backend/branding/data/.gitkeep
Normal 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",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 637 B After Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 654 B After Width: | Height: | Size: 654 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
2
backend/src/constants/badges.ts
Normal 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
@ -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()
|
||||
}
|
||||
})()
|
||||
17
backend/src/db/data-branding.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})()
|
||||
0
backend/src/db/data/.gitkeep
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
185
backend/src/db/seed/badges.ts
Normal 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',
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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() },
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
|
||||
667
backend/src/schema/resolvers/badges.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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!' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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: {
|
||||
|
||||
2
webapp/constants/badges.js
Normal 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
|
||||
@ -29,7 +29,7 @@ export default (i18n) => {
|
||||
commentedCount
|
||||
followedByCount
|
||||
followedByCurrentUser
|
||||
badges {
|
||||
badgeTrophies {
|
||||
id
|
||||
icon
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export const locationFragment = (lang) => gql`
|
||||
|
||||
export const badgesFragment = gql`
|
||||
fragment badges on User {
|
||||
badges {
|
||||
badgeTrophies {
|
||||
id
|
||||
icon
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||