mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge pull request #4125 from Ocelot-Social-Community/invite-codes
feat: Invite codes
This commit is contained in:
commit
8a4f64e030
@ -5,6 +5,7 @@ import { hashSync } from 'bcryptjs'
|
||||
import { Factory } from 'rosie'
|
||||
import { getDriver, getNeode } from './neo4j'
|
||||
import CONFIG from '../config/index.js'
|
||||
import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js'
|
||||
|
||||
const neode = getNeode()
|
||||
|
||||
@ -205,7 +206,7 @@ const emailDefaults = {
|
||||
}
|
||||
|
||||
Factory.define('emailAddress')
|
||||
.attr(emailDefaults)
|
||||
.attrs(emailDefaults)
|
||||
.after((buildObject, options) => {
|
||||
return neode.create('EmailAddress', buildObject)
|
||||
})
|
||||
@ -216,6 +217,28 @@ Factory.define('unverifiedEmailAddress')
|
||||
return neode.create('UnverifiedEmailAddress', buildObject)
|
||||
})
|
||||
|
||||
const inviteCodeDefaults = {
|
||||
code: () => generateInviteCode(),
|
||||
createdAt: () => new Date().toISOString(),
|
||||
expiresAt: () => null,
|
||||
}
|
||||
|
||||
Factory.define('inviteCode')
|
||||
.attrs(inviteCodeDefaults)
|
||||
.option('generatedById', null)
|
||||
.option('generatedBy', ['generatedById'], (generatedById) => {
|
||||
if (generatedById) return neode.find('User', generatedById)
|
||||
return Factory.build('user')
|
||||
})
|
||||
.after(async (buildObject, options) => {
|
||||
const [inviteCode, generatedBy] = await Promise.all([
|
||||
neode.create('InviteCode', buildObject),
|
||||
options.generatedBy,
|
||||
])
|
||||
await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
|
||||
return inviteCode
|
||||
})
|
||||
|
||||
Factory.define('location')
|
||||
.attrs({
|
||||
name: 'Germany',
|
||||
|
||||
@ -541,6 +541,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
),
|
||||
])
|
||||
|
||||
await Factory.build(
|
||||
'inviteCode',
|
||||
{
|
||||
code: 'AAAAAA',
|
||||
},
|
||||
{
|
||||
generatedBy: jennyRostock,
|
||||
},
|
||||
)
|
||||
|
||||
authenticatedUser = await louie.toJson()
|
||||
const mention1 =
|
||||
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
|
||||
|
||||
@ -106,6 +106,8 @@ export default shield(
|
||||
notifications: isAuthenticated,
|
||||
Donations: isAuthenticated,
|
||||
userData: isAuthenticated,
|
||||
MyInviteCodes: isAuthenticated,
|
||||
isValidInviteCode: allow,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
@ -149,6 +151,7 @@ export default shield(
|
||||
pinPost: isAdmin,
|
||||
unpinPost: isAdmin,
|
||||
UpdateDonations: isAdmin,
|
||||
GenerateInviteCode: isAuthenticated,
|
||||
},
|
||||
User: {
|
||||
email: or(isMyOwn, isAdmin),
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
export default {
|
||||
code: { type: 'string', primary: true },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
token: { type: 'string', primary: true, token: true },
|
||||
generatedBy: {
|
||||
expiresAt: { type: 'string', isoDate: true, default: null },
|
||||
generated: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
activated: {
|
||||
redeemed: {
|
||||
type: 'relationship',
|
||||
relationship: 'ACTIVATED',
|
||||
target: 'EmailAddress',
|
||||
direction: 'out',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
}
|
||||
@ -100,6 +100,18 @@ export default {
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
inviteCodes: {
|
||||
type: 'relationship',
|
||||
relationship: 'GENERATED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
redeemedInviteCode: {
|
||||
type: 'relationship',
|
||||
relationship: 'REDEEMED',
|
||||
target: 'InviteCode',
|
||||
direction: 'out',
|
||||
},
|
||||
termsAndConditionsAgreedVersion: {
|
||||
type: 'string',
|
||||
allow: [null],
|
||||
|
||||
@ -15,4 +15,5 @@ export default {
|
||||
Donations: require('./Donations.js').default,
|
||||
Report: require('./Report.js').default,
|
||||
Migration: require('./Migration.js').default,
|
||||
InviteCode: require('./InviteCode.js').default,
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export default function generateInviteCode() {
|
||||
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
|
||||
return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => {
|
||||
// n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65
|
||||
// else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48
|
||||
return String.fromCharCode(n > 9 ? n + 55 : n + 48)
|
||||
}).join('')
|
||||
}
|
||||
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
109
backend/src/schema/resolvers/inviteCodes.js
Normal file
@ -0,0 +1,109 @@
|
||||
import generateInviteCode 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
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
MyInviteCodes: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode)
|
||||
RETURN properties(ic) AS inviteCodes`,
|
||||
{
|
||||
userId,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCodes'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return txResult
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
isValidInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const { code } = args
|
||||
if (!code) return false
|
||||
const session = context.driver.session()
|
||||
const readTxResultPromise = session.readTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (ic:InviteCode { code: toUpper($code) })
|
||||
RETURN
|
||||
CASE
|
||||
WHEN ic.expiresAt IS NULL THEN true
|
||||
WHEN datetime(ic.expiresAt) >= datetime() THEN true
|
||||
ELSE false END AS result`,
|
||||
{
|
||||
code,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('result'))
|
||||
})
|
||||
try {
|
||||
const txResult = await readTxResultPromise
|
||||
return !!txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = context
|
||||
const session = context.driver.session()
|
||||
let code = generateInviteCode()
|
||||
while (!(await uniqueInviteCode(session, code))) {
|
||||
code = generateInviteCode()
|
||||
}
|
||||
const writeTxResultPromise = session.writeTransaction(async (txc) => {
|
||||
const result = await txc.run(
|
||||
`MATCH (user:User {id: $userId})
|
||||
MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code })
|
||||
ON CREATE SET
|
||||
ic.createdAt = toString(datetime()),
|
||||
ic.expiresAt = $expiresAt
|
||||
RETURN ic AS inviteCode`,
|
||||
{
|
||||
userId,
|
||||
code,
|
||||
expiresAt: args.expiresAt,
|
||||
},
|
||||
)
|
||||
return result.records.map((record) => record.get('inviteCode').properties)
|
||||
})
|
||||
try {
|
||||
const txResult = await writeTxResultPromise
|
||||
return txResult[0]
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
InviteCode: {
|
||||
...Resolver('InviteCode', {
|
||||
idAttribute: 'code',
|
||||
undefinedToNull: ['expiresAt'],
|
||||
hasOne: {
|
||||
generatedBy: '<-[:GENERATED]-(related:User)',
|
||||
},
|
||||
hasMany: {
|
||||
redeemedBy: '<-[:REDEEMED]-(related:User)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
200
backend/src/schema/resolvers/inviteCodes.spec.js
Normal file
@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const myInviteCodesQuery = gql`
|
||||
query {
|
||||
MyInviteCodes {
|
||||
code
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const isValidInviteCodeQuery = gql`
|
||||
query($code: ID) {
|
||||
isValidInviteCode(code: $code)
|
||||
}
|
||||
`
|
||||
|
||||
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('as unauthenticated user', () => {
|
||||
it('cannot generate invite codes', async () => {
|
||||
await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
GenerateInviteCode: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('cannot query invite codes', async () => {
|
||||
await expect(query({ query: myInviteCodesQuery })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
||||
}),
|
||||
]),
|
||||
data: {
|
||||
MyInviteCodes: 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),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
let inviteCodes
|
||||
|
||||
it('returns the created invite codes when queried', async () => {
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not return the created invite codes of other users when queried', async () => {
|
||||
await Factory.build('inviteCode')
|
||||
const response = await query({ query: myInviteCodesQuery })
|
||||
inviteCodes = response.data.MyInviteCodes
|
||||
expect(inviteCodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('validates an invite code without expiresAt', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code in lower case', async () => {
|
||||
const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: unExpiringInviteCode.toLowerCase() },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('validates an invite code with expiresAt in the future', async () => {
|
||||
const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code
|
||||
const result = await query({
|
||||
query: isValidInviteCodeQuery,
|
||||
variables: { code: expiringInviteCode },
|
||||
})
|
||||
expect(result.data.isValidInviteCode).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which expired in the past', async () => {
|
||||
const lastWeek = new Date()
|
||||
lastWeek.setDate(lastWeek.getDate() - 7)
|
||||
const inviteCode = await Factory.build('inviteCode', {
|
||||
expiresAt: lastWeek.toISOString(),
|
||||
})
|
||||
const code = inviteCode.get('code')
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not validate an invite code which does not exits', async () => {
|
||||
const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } })
|
||||
expect(result.data.isValidInviteCode).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -293,6 +293,7 @@ export default {
|
||||
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
||||
invitedBy: '<-[:INVITED]-(related:User)',
|
||||
location: '-[:IS_IN]->(related:Location)',
|
||||
redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)',
|
||||
},
|
||||
hasMany: {
|
||||
followedBy: '<-[:FOLLOWS]-(related:User)',
|
||||
@ -304,6 +305,7 @@ export default {
|
||||
shouted: '-[:SHOUTED]->(related:Post)',
|
||||
categories: '-[:CATEGORIZED]->(related:Category)',
|
||||
badges: '<-[:REWARDED]-(related:Badge)',
|
||||
inviteCodes: '-[:GENERATED]->(related:InviteCode)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
17
backend/src/schema/types/type/InviteCode.gql
Normal file
17
backend/src/schema/types/type/InviteCode.gql
Normal file
@ -0,0 +1,17 @@
|
||||
type InviteCode {
|
||||
code: ID!
|
||||
createdAt: String!
|
||||
generatedBy: User @relation(name: "GENERATED", direction: "IN")
|
||||
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
|
||||
expiresAt: String
|
||||
}
|
||||
|
||||
|
||||
type Mutation {
|
||||
GenerateInviteCode(expiresAt: String = null): InviteCode
|
||||
}
|
||||
|
||||
type Query {
|
||||
MyInviteCodes: [InviteCode]
|
||||
isValidInviteCode(code: ID!): Boolean
|
||||
}
|
||||
@ -56,6 +56,9 @@ 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: "GENERATED", direction: "OUT")
|
||||
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
|
||||
|
||||
# Is the currently logged in user following that user?
|
||||
followedByCurrentUser: Boolean! @cypher(
|
||||
statement: """
|
||||
@ -83,7 +86,7 @@ type User {
|
||||
RETURN COUNT(user) >= 1
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# contributions: [WrittenPost]!
|
||||
# contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
|
||||
# @cypher(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user