diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 75caa3c0e..ac709279d 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -149,7 +149,7 @@ export default shield( pinPost: isAdmin, unpinPost: isAdmin, UpdateDonations: isAdmin, - CreateInviteCode: isAuthenticated, + GenerateInviteCode: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/models/User.js b/backend/src/models/User.js index e9e21e856..6cfd22268 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -100,15 +100,15 @@ export default { target: 'User', direction: 'in', }, - createdInvite: { + inviteCodes: { type: 'relationship', - relationship: 'CREATED', + relationship: 'GENERATED', target: 'InviteCode', direction: 'out', }, - usedInvite: { + redeemedInviteCode: { type: 'relationship', - relationship: 'USED', + relationship: 'REDEEMED', target: 'InviteCode', direction: 'out', }, diff --git a/backend/src/schema/resolvers/helpers/generateInviteCode.js b/backend/src/schema/resolvers/helpers/generateInviteCode.js index 1d42c2d5d..2b3713e3d 100644 --- a/backend/src/schema/resolvers/helpers/generateInviteCode.js +++ b/backend/src/schema/resolvers/helpers/generateInviteCode.js @@ -1,5 +1,5 @@ export default function generateInviteCode() { - return Array.from({length: 6}, (n = Math.floor(Math.random() * 36)) => { + return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => { return String.fromCharCode(n > 9 ? n + 55 : n + 48) - }).join('') + }).join('') } diff --git a/backend/src/schema/resolvers/inviteCodes.js b/backend/src/schema/resolvers/inviteCodes.js index 450339add..7a39bc081 100644 --- a/backend/src/schema/resolvers/inviteCodes.js +++ b/backend/src/schema/resolvers/inviteCodes.js @@ -1,65 +1,60 @@ -import { neo4jgraphql } from 'neo4j-graphql-js' import generateInvieCode from './helpers/generateInviteCode' import Resolver from './helpers/Resolver' const uniqueInviteCode = async (session, code) => { return session.readTransaction(async (txc) => { - const result = await txc.run( - `MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, - { code }, - ) - return parseInt(String(result.records[0].get('count'))) === 0 + const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, { + code, + }) + return parseInt(String(result.records[0].get('count'))) === 0 }) } export default { Mutation: { - CreateInviteCode: async (_parent, args, context, _resolveInfo) => { + GenerateInviteCode: async (_parent, args, context, _resolveInfo) => { const { user: { id: userId }, } = context const session = context.driver.session() let code = generateInvieCode() let response - while(!await uniqueInviteCode(session, code)) { + while (!(await uniqueInviteCode(session, code))) { code = generateInvieCode() } const writeTxResultPromise = session.writeTransaction(async (txc) => { const result = await txc.run( `MATCH (user:User {id: $userId}) - MERGE (user)-[:CREATED]->(ic:InviteCode { - code: $code, - createdAt: toString(datetime()), - uses: $uses, - maxUses: $maxUses, - active: true - }) RETURN ic AS inviteCode`, + MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code }) + ON CREATE SET + ic.createdAt = toString(datetime()), + ic.expiresAt = $expiresAt + RETURN ic AS inviteCode`, { userId, code, - maxUses: args.maxUses, - uses: 0, + expiresAt: args.expiresAt, }, ) return result.records.map((record) => record.get('inviteCode').properties) }) try { const txResult = await writeTxResultPromise - console.log(txResult) response = txResult[0] } finally { session.close() } return response - } + }, }, InviteCode: { ...Resolver('InviteCode', { + undefinedToNull: ['expiresAt'], hasOne: { - createdBy: '<-[:CREATED]-(related:User)', + generatedBy: '<-[:GENERATED]-(related:User)', }, hasMany: { - usedBy: '<-[:USED]-(related:User)', + redeemedBy: '<-[:REDEEMED]-(related:User)', }, }), }, diff --git a/backend/src/schema/resolvers/inviteCodes.spec.js b/backend/src/schema/resolvers/inviteCodes.spec.js new file mode 100644 index 000000000..04a8d2a5a --- /dev/null +++ b/backend/src/schema/resolvers/inviteCodes.spec.js @@ -0,0 +1,113 @@ +import Factory, { cleanDatabase } from '../../db/factories' +import { getDriver } from '../../db/neo4j' +import { gql } from '../../helpers/jest' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +let user +// let query +let mutate + +const driver = getDriver() + +const generateInviteCodeMutation = gql` + mutation($expiresAt: String = null) { + GenerateInviteCode(expiresAt: $expiresAt) { + code + createdAt + expiresAt + } + } +` + +beforeAll(async () => { + await cleanDatabase() + const { server } = createServer({ + context: () => { + return { + driver, + user, + } + }, + }) + // query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +describe('inviteCodes', () => { + describe('generate invite code', () => { + describe('as unauthenticated user', () => { + it('returns permission denied error', async () => { + await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }), + ]), + data: { + GenerateInviteCode: null, + }, + }), + ) + }) + }) + + describe('as authenticated user', () => { + beforeAll(async () => { + const authenticatedUser = await Factory.build( + 'user', + { + role: 'user', + }, + { + email: 'user@example.org', + password: '1234', + }, + ) + user = await authenticatedUser.toJson() + }) + + it('generates an invite code without expiresAt', async () => { + await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual( + expect.objectContaining({ + errors: undefined, + data: { + GenerateInviteCode: { + code: expect.stringMatching(/^[0-9A-Z]{6,6}$/), + expiresAt: null, + createdAt: expect.any(String), + }, + }, + }), + ) + }) + + it('generates an invite code with expiresAt', async () => { + const nextWeek = new Date() + nextWeek.setDate(nextWeek.getDate() + 7) + await expect( + mutate({ + mutation: generateInviteCodeMutation, + variables: { expiresAt: nextWeek.toISOString() }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: undefined, + data: { + GenerateInviteCode: { + code: expect.stringMatching(/^[0-9A-Z]{6,6}$/), + expiresAt: nextWeek.toISOString(), + createdAt: expect.any(String), + }, + }, + }), + ) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index c718187c1..edc81482f 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -293,7 +293,7 @@ export default { avatar: '-[:AVATAR_IMAGE]->(related:Image)', invitedBy: '<-[:INVITED]-(related:User)', location: '-[:IS_IN]->(related:Location)', - usedInviteCode: '-[:USED]->(related:InviteCode)', + redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)', }, hasMany: { followedBy: '<-[:FOLLOWS]-(related:User)', @@ -305,7 +305,7 @@ export default { shouted: '-[:SHOUTED]->(related:Post)', categories: '-[:CATEGORIZED]->(related:Category)', badges: '<-[:REWARDED]-(related:Badge)', - inviteCodes: '-[:CREATED]->(related:InviteCode)', + inviteCodes: '-[:GENERATED]->(related:InviteCode)', }, }), }, diff --git a/backend/src/schema/types/type/InviteCode.gql b/backend/src/schema/types/type/InviteCode.gql index b20235620..8ed7fbcab 100644 --- a/backend/src/schema/types/type/InviteCode.gql +++ b/backend/src/schema/types/type/InviteCode.gql @@ -1,14 +1,12 @@ type InviteCode { code: ID! createdAt: String - uses: Int! - maxUses: Int! - createdBy: User @relation(name: "CREATED", direction: "IN") - usedBy: [User] @relation(name: "USED", direction: "IN") - active: Boolean! + generatedBy: User @relation(name: "GENERATED", direction: "IN") + redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN") + expiresAt: String } type Mutation { - CreateInviteCode(maxUses: Int = 1): InviteCode + GenerateInviteCode(expiresAt: String = null): InviteCode } diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index bbee746b9..f5a561f65 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -56,8 +56,8 @@ type User { followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") - inviteCodes: [InviteCode] @relation(name: "CREATED", direction: "OUT") - usedInviteCode: InviteCode @relation(name: "USED", direction: "OUT") + inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") + redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") # Is the currently logged in user following that user? followedByCurrentUser: Boolean! @cypher(