Skip to content

Commit

Permalink
Merge pull request #4125 from Ocelot-Social-Community/invite-codes
Browse files Browse the repository at this point in the history
feat: Invite codes
  • Loading branch information
Mogge authored Jan 20, 2021
2 parents fcf6b95 + 2777e33 commit 8a4f64e
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 8 deletions.
25 changes: 24 additions & 1 deletion backend/src/db/factories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -205,7 +206,7 @@ const emailDefaults = {
}

Factory.define('emailAddress')
.attr(emailDefaults)
.attrs(emailDefaults)
.after((buildObject, options) => {
return neode.create('EmailAddress', buildObject)
})
Expand All @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions backend/src/db/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?'
Expand Down
3 changes: 3 additions & 0 deletions backend/src/middleware/permissionsMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export default shield(
notifications: isAuthenticated,
Donations: isAuthenticated,
userData: isAuthenticated,
MyInviteCodes: isAuthenticated,
isValidInviteCode: allow,
},
Mutation: {
'*': deny,
Expand Down Expand Up @@ -149,6 +151,7 @@ export default shield(
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
GenerateInviteCode: isAuthenticated,
},
User: {
email: or(isMyOwn, isAdmin),
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
},
}
12 changes: 12 additions & 0 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions backend/src/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
8 changes: 8 additions & 0 deletions backend/src/schema/resolvers/helpers/generateInviteCode.js
Original file line number Diff line number Diff line change
@@ -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 changes: 109 additions & 0 deletions backend/src/schema/resolvers/inviteCodes.js
Original file line number Diff line number Diff line change
@@ -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)',
},
}),
},
}
Loading

0 comments on commit 8a4f64e

Please sign in to comment.