diff --git a/backend/public/img/badges/default_trophy.svg b/backend/public/img/badges/default_trophy.svg new file mode 100644 index 000000000..b203cdfc6 --- /dev/null +++ b/backend/public/img/badges/default_trophy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/public/img/badges/default_verification.svg b/backend/public/img/badges/default_verification.svg new file mode 100644 index 000000000..7bde29f35 --- /dev/null +++ b/backend/public/img/badges/default_verification.svg @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/backend/src/graphql/types/type/Badge.gql b/backend/src/graphql/types/type/Badge.gql index cbfe0193d..8cdad2ee7 100644 --- a/backend/src/graphql/types/type/Badge.gql +++ b/backend/src/graphql/types/type/Badge.gql @@ -4,6 +4,7 @@ type Badge { icon: String! createdAt: String description: String! + isDefault: Boolean! rewarded: [User]! @relation(name: "REWARDED", direction: "OUT") verifies: [User]! @relation(name: "VERIFIES", direction: "OUT") diff --git a/backend/src/graphql/types/type/User.gql b/backend/src/graphql/types/type/User.gql index 751931d5b..81dd9cf5b 100644 --- a/backend/src/graphql/types/type/User.gql +++ b/backend/src/graphql/types/type/User.gql @@ -125,10 +125,10 @@ type User { categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") - badgeVerification: Badge @relation(name: "VERIFIES", direction: "IN") + badgeVerification: Badge! @neo4j_ignore badgeTrophies: [Badge]! @relation(name: "REWARDED", direction: "IN") badgeTrophiesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") - badgeTrophiesSelected: [Badge]! @neo4j_ignore + badgeTrophiesSelected: [Badge!]! @neo4j_ignore badgeTrophiesUnused: [Badge]! @neo4j_ignore badgeTrophiesUnusedCount: Int! @neo4j_ignore diff --git a/backend/src/schema/resolvers/badges.spec.ts b/backend/src/schema/resolvers/badges.spec.ts index 17fe46c99..2588c9b5c 100644 --- a/backend/src/schema/resolvers/badges.spec.ts +++ b/backend/src/schema/resolvers/badges.spec.ts @@ -100,6 +100,7 @@ describe('Badges', () => { id badgeVerification { id + isDefault } badgeTrophies { id @@ -204,7 +205,7 @@ describe('Badges', () => { data: { setVerificationBadge: { id: 'regular-user-id', - badgeVerification: { id: 'verification_moderator' }, + badgeVerification: { id: 'verification_moderator', isDefault: false }, badgeTrophies: [], }, }, @@ -226,7 +227,7 @@ describe('Badges', () => { data: { setVerificationBadge: { id: 'regular-user-id', - badgeVerification: { id: 'verification_admin' }, + badgeVerification: { id: 'verification_admin', isDefault: false }, badgeTrophies: [], }, }, @@ -255,7 +256,7 @@ describe('Badges', () => { data: { setVerificationBadge: { id: 'regular-user-2-id', - badgeVerification: { id: 'verification_moderator' }, + badgeVerification: { id: 'verification_moderator', isDefault: false }, badgeTrophies: [], }, }, @@ -299,6 +300,7 @@ describe('Badges', () => { id badgeVerification { id + isDefault } badgeTrophies { id @@ -403,7 +405,7 @@ describe('Badges', () => { data: { rewardTrophyBadge: { id: 'regular-user-id', - badgeVerification: null, + badgeVerification: { id: 'default_verification', isDefault: true }, badgeTrophies: [{ id: 'trophy_rhino' }], }, }, @@ -530,6 +532,7 @@ describe('Badges', () => { id badgeVerification { id + isDefault } badgeTrophies { id @@ -596,7 +599,7 @@ describe('Badges', () => { data: { revokeBadge: { id: 'regular-user-id', - badgeVerification: { id: 'verification_moderator' }, + badgeVerification: { id: 'verification_moderator', isDefault: false }, badgeTrophies: [], }, }, @@ -610,7 +613,7 @@ describe('Badges', () => { data: { revokeBadge: { id: 'regular-user-id', - badgeVerification: { id: 'verification_moderator' }, + badgeVerification: { id: 'verification_moderator', isDefault: false }, badgeTrophies: [], }, }, @@ -631,7 +634,7 @@ describe('Badges', () => { data: { revokeBadge: { id: 'regular-user-id', - badgeVerification: null, + badgeVerification: { id: 'default_verification', isDefault: true }, badgeTrophies: [{ id: 'trophy_rhino' }], }, }, @@ -659,7 +662,7 @@ describe('Badges', () => { data: { revokeBadge: { id: 'regular-user-id', - badgeVerification: null, + badgeVerification: { id: 'default_verification', isDefault: true }, badgeTrophies: [{ id: 'trophy_rhino' }], }, }, diff --git a/backend/src/schema/resolvers/badges.ts b/backend/src/schema/resolvers/badges.ts index 430e3bf75..587204b54 100644 --- a/backend/src/schema/resolvers/badges.ts +++ b/backend/src/schema/resolvers/badges.ts @@ -6,6 +6,22 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { neo4jgraphql } from 'neo4j-graphql-js' +export const defaultTrophyBadge = { + id: 'default_trophy', + type: 'trophy', + icon: '/img/badges/default_trophy.svg', + description: '', + createdAt: '', +} + +export const defaultVerificationBadge = { + id: 'default_verification', + type: 'verification', + icon: '/img/badges/default_verification.svg', + description: '', + createdAt: '', +} + export default { Query: { Badge: async (object, args, context, resolveInfo) => @@ -123,4 +139,8 @@ export default { } }, }, + Badge: { + isDefault: async (parent, _params, _context, _resolveInfo) => + [defaultTrophyBadge.id, defaultVerificationBadge.id].includes(parent.id), + }, } diff --git a/backend/src/schema/resolvers/users.spec.ts b/backend/src/schema/resolvers/users.spec.ts index 8d082b682..d4f5e00eb 100644 --- a/backend/src/schema/resolvers/users.spec.ts +++ b/backend/src/schema/resolvers/users.spec.ts @@ -81,6 +81,7 @@ const setTrophyBadgeSelected = gql` badgeTrophiesCount badgeTrophiesSelected { id + isDefault } badgeTrophiesUnused { id @@ -96,6 +97,7 @@ const resetTrophyBadgesSelected = gql` badgeTrophiesCount badgeTrophiesSelected { id + isDefault } badgeTrophiesUnused { id @@ -1242,15 +1244,40 @@ describe('setTrophyBadgeSelected', () => { badgeTrophiesSelected: [ { id: 'trophy_bear', + isDefault: false, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, }, - null, - null, - null, - null, - null, - null, - null, - null, ], badgeTrophiesUnused: [ { @@ -1275,17 +1302,40 @@ describe('setTrophyBadgeSelected', () => { badgeTrophiesSelected: [ { id: 'trophy_bear', + isDefault: false, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, }, - null, - null, - null, - null, { id: 'trophy_panda', + isDefault: false, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, }, - null, - null, - null, ], badgeTrophiesUnused: [], badgeTrophiesUnusedCount: 0, @@ -1295,8 +1345,8 @@ describe('setTrophyBadgeSelected', () => { ) }) - describe('set badge to null', () => { - it('returns the user with no badge set on the selected slot', async () => { + describe('set badge to null or default', () => { + beforeEach(async () => { await mutate({ mutation: setTrophyBadgeSelected, variables: { slot: 0, badgeId: 'trophy_bear' }, @@ -1305,7 +1355,9 @@ describe('setTrophyBadgeSelected', () => { mutation: setTrophyBadgeSelected, variables: { slot: 5, badgeId: 'trophy_panda' }, }) + }) + it('returns the user with no badge set on the selected slot when sending null', async () => { await expect( mutate({ mutation: setTrophyBadgeSelected, @@ -1319,15 +1371,101 @@ describe('setTrophyBadgeSelected', () => { badgeTrophiesSelected: [ { id: 'trophy_bear', + isDefault: false, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + ], + badgeTrophiesUnused: [ + { + id: 'trophy_panda', + }, + ], + badgeTrophiesUnusedCount: 1, + }, + }, + }), + ) + }) + + it('returns the user with no badge set on the selected slot when sending default_trophy', async () => { + await expect( + mutate({ + mutation: setTrophyBadgeSelected, + variables: { slot: 5, badgeId: 'default_trophy' }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + setTrophyBadgeSelected: { + badgeTrophiesCount: 2, + badgeTrophiesSelected: [ + { + id: 'trophy_bear', + isDefault: false, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, }, - null, - null, - null, - null, - null, - null, - null, - null, ], badgeTrophiesUnused: [ { @@ -1411,7 +1549,44 @@ describe('resetTrophyBadgesSelected', () => { data: { resetTrophyBadgesSelected: { badgeTrophiesCount: 2, - badgeTrophiesSelected: [null, null, null, null, null, null, null, null, null], + badgeTrophiesSelected: [ + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + { + id: 'default_trophy', + isDefault: true, + }, + ], badgeTrophiesUnused: [ { id: 'trophy_panda', diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index 913427085..c165e8e44 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -11,6 +11,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges' import { getNeode } from '@db/neo4j' +import { defaultTrophyBadge, defaultVerificationBadge } from './badges' import log from './helpers/databaseLogger' import Resolver from './helpers/Resolver' import { mergeImage, deleteImage } from './images/images' @@ -412,13 +413,15 @@ export default { MERGE (user)-[:SELECTED{slot: toInteger($slot)}]->(badge) RETURN user {.*} ` - const queryNull = ` + const queryEmpty = ` MATCH (user:User {id: $userId}) OPTIONAL MATCH (user)-[slotRelation:SELECTED {slot: $slot}]->(:Badge) DELETE slotRelation RETURN user {.*} ` - const result = await transaction.run(badgeId ? queryBadge : queryNull, { + const isDefault = !badgeId || badgeId === defaultTrophyBadge.id + + const result = await transaction.run(isDefault ? queryEmpty : queryBadge, { userId, badgeId, slot, @@ -538,7 +541,7 @@ export default { }) try { const badgesSelected = await query - const result = Array(TROPHY_BADGES_SELECTED_MAX).fill(null) + const result = Array(TROPHY_BADGES_SELECTED_MAX).fill(defaultTrophyBadge) badgesSelected.map((record) => { result[record.get('slot')] = record.get('badge') return true @@ -550,21 +553,17 @@ export default { session.close() } }, - badgeTrophiesUnused: async (_parent, _params, context, _resolveInfo) => { - const { - user: { id: userId }, - } = context - + badgeTrophiesUnused: async (parent, _params, context, _resolveInfo) => { const session = context.driver.session() - const query = session.writeTransaction(async (transaction) => { + const query = session.readTransaction(async (transaction) => { const result = await transaction.run( ` - MATCH (user:User {id: $userId})<-[:REWARDED]-(badge:Badge) + MATCH (user:User {id: $parent.id})<-[:REWARDED]-(badge:Badge) WHERE NOT (user)-[:SELECTED]-(badge) RETURN badge {.*} `, - { userId }, + { parent }, ) return result.records.map((record) => record.get('badge')) }) @@ -576,21 +575,17 @@ export default { session.close() } }, - badgeTrophiesUnusedCount: async (_parent, _params, context, _resolveInfo) => { - const { - user: { id: userId }, - } = context - + badgeTrophiesUnusedCount: async (parent, _params, context, _resolveInfo) => { const session = context.driver.session() - const query = session.writeTransaction(async (transaction) => { + const query = session.readTransaction(async (transaction) => { const result = await transaction.run( ` - MATCH (user:User {id: $userId})<-[:REWARDED]-(badge:Badge) + MATCH (user:User {id: $parent.id})<-[:REWARDED]-(badge:Badge) WHERE NOT (user)-[:SELECTED]-(badge) RETURN toString(COUNT(badge)) as count `, - { userId }, + { parent }, ) return result.records.map((record) => record.get('count'))[0] }) @@ -602,6 +597,28 @@ export default { session.close() } }, + badgeVerification: async (parent, _params, context, _resolveInfo) => { + const session = context.driver.session() + + const query = session.writeTransaction(async (transaction) => { + const result = await transaction.run( + ` + MATCH (user:User {id: $parent.id})<-[:VERIFIES]-(verification:Badge) + RETURN verification {.*} + `, + { parent }, + ) + return result.records.map((record) => record.get('verification'))[0] + }) + try { + const result = await query + return result ?? defaultVerificationBadge + } catch (error) { + throw new Error(error) + } finally { + session.close() + } + }, ...Resolver('User', { undefinedToNull: [ 'actorId', @@ -642,7 +659,6 @@ export default { invitedBy: '<-[:INVITED]-(related:User)', location: '-[:IS_IN]->(related:Location)', redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)', - badgeVerification: '<-[:VERIFIES]-(related:Badge)', }, hasMany: { followedBy: '<-[:FOLLOWS]-(related:User)',