feat(backend): group invite codes (#8499)

* invite codes refactor

typo

* lint fixes

* remove duplicate initeCodes on User

* fix typo

* clean permissionMiddleware

* dummy permissions

* separate validateInviteCode call

* permissions group & user

* test validateInviteCode + adjustments

* more validateInviteCode fixes

* missing test

* generatePersonalInviteCode

* generateGroupInviteCode

* old tests

* lint fixes

* more lint fixes

* fix validateInviteCode

* fix redeemInviteCode, fix signup

* fix all tests

* fix lint

* uniform types in config

* test & fix invalidateInviteCode

* cleanup test

* fix & test redeemInviteCode

* permissions

* fix Group->inviteCodes

* more cleanup

* improve tests

* fix code generation

* cleanup

* order inviteCodes result on User and Group

* lint

* test max invite codes + fix

* better description of collision

* tests: properly define group ids

* reused old group query

* reuse old Groupmembers query

* remove duplicate skip

* update comment

* fix uniqueInviteCode

* fix test
This commit is contained in:
Ulf Gebhardt 2025-05-08 21:18:40 +02:00 committed by GitHub
parent e3864b1f9d
commit 3f4d648562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2034 additions and 631 deletions

View File

@ -117,6 +117,10 @@ const options = {
ORGANIZATION_URL: emails.ORGANIZATION_LINK,
PUBLIC_REGISTRATION: env.PUBLIC_REGISTRATION === 'true' || false,
INVITE_REGISTRATION: env.INVITE_REGISTRATION !== 'false', // default = true
INVITE_CODES_PERSONAL_PER_USER:
(env.INVITE_CODES_PERSONAL_PER_USER && parseInt(env.INVITE_CODES_PERSONAL_PER_USER)) || 7,
INVITE_CODES_GROUP_PER_USER:
(env.INVITE_CODES_GROUP_PER_USER && parseInt(env.INVITE_CODES_GROUP_PER_USER)) || 7,
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
}

View File

@ -4,7 +4,7 @@ import type { Driver } from 'neo4j-driver'
export const query =
(driver: Driver) =>
async ({ query, variables = {} }: { driver; query: string; variables: object }) => {
async ({ query, variables = {} }: { query: string; variables: object }) => {
const session = driver.session()
const result = session.readTransaction(async (transaction) => {
@ -19,9 +19,9 @@ export const query =
}
}
export const mutate =
export const write =
(driver: Driver) =>
async ({ query, variables = {} }: { driver; query: string; variables: object }) => {
async ({ query, variables = {} }: { query: string; variables: object }) => {
const session = driver.session()
const result = session.writeTransaction(async (transaction) => {
@ -44,6 +44,6 @@ export default () => {
driver,
neode,
query: query(driver),
mutate: mutate(driver),
write: write(driver),
}
}

View File

@ -10,7 +10,7 @@ import { Factory } from 'rosie'
import slugify from 'slug'
import { v4 as uuid } from 'uuid'
import generateInviteCode from '@graphql/resolvers/helpers/generateInviteCode'
import { generateInviteCode } from '@graphql/resolvers/inviteCodes'
import { getDriver, getNeode } from './neo4j'
@ -268,17 +268,27 @@ const inviteCodeDefaults = {
Factory.define('inviteCode')
.attrs(inviteCodeDefaults)
.option('groupId', null)
.option('group', ['groupId'], (groupId) => {
if (groupId) {
return neode.find('Group', groupId)
}
})
.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([
const [inviteCode, generatedBy, group] = await Promise.all([
neode.create('InviteCode', buildObject),
options.generatedBy,
options.group,
])
await Promise.all([inviteCode.relateTo(generatedBy, 'generated')])
await inviteCode.relateTo(generatedBy, 'generated')
if (group) {
await inviteCode.relateTo(group, 'invitesTo')
}
return inviteCode
})

View File

@ -14,4 +14,10 @@ export default {
target: 'User',
direction: 'in',
},
invitesTo: {
type: 'relationship',
relationship: 'INVITES_TO',
target: 'Group',
direction: 'out',
},
}

View File

@ -0,0 +1,36 @@
import gql from 'graphql-tag'
export const Group = gql`
query Group($isMember: Boolean, $id: ID, $slug: String) {
Group(isMember: $isMember, id: $id, slug: $slug) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
avatar {
url
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag'
export const GroupMembers = gql`
query GroupMembers($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
export const currentUser = gql`
query currentUser {
currentUser {
following {
name
}
inviteCodes {
code
redeemedByCount
}
}
}
`

View File

@ -0,0 +1,36 @@
import gql from 'graphql-tag'
export const generateGroupInviteCode = gql`
mutation generateGroupInviteCode($groupId: ID!, $expiresAt: String, $comment: String) {
generateGroupInviteCode(groupId: $groupId, expiresAt: $expiresAt, comment: $comment) {
code
createdAt
generatedBy {
id
name
avatar {
url
}
}
redeemedBy {
id
name
avatar {
url
}
}
expiresAt
comment
invitedTo {
id
groupType
name
about
avatar {
url
}
}
isValid
}
}
`

View File

@ -0,0 +1,36 @@
import gql from 'graphql-tag'
export const generatePersonalInviteCode = gql`
mutation generatePersonalInviteCode($expiresAt: String, $comment: String) {
generatePersonalInviteCode(expiresAt: $expiresAt, comment: $comment) {
code
createdAt
generatedBy {
id
name
avatar {
url
}
}
redeemedBy {
id
name
avatar {
url
}
}
expiresAt
comment
invitedTo {
id
groupType
name
about
avatar {
url
}
}
isValid
}
}
`

View File

@ -1,14 +0,0 @@
import gql from 'graphql-tag'
export const groupMembersQuery = () => {
return gql`
query ($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
}
}
`
}

View File

@ -1,38 +0,0 @@
import gql from 'graphql-tag'
export const groupQuery = () => {
return gql`
query ($isMember: Boolean, $id: ID, $slug: String) {
Group(isMember: $isMember, id: $id, slug: $slug) {
id
name
slug
createdAt
updatedAt
disabled
deleted
about
description
descriptionExcerpt
groupType
actionRadius
categories {
id
slug
name
icon
}
avatar {
url
}
locationName
location {
name
nameDE
nameEN
}
myRole
}
}
`
}

View File

@ -0,0 +1,36 @@
import gql from 'graphql-tag'
export const invalidateInviteCode = gql`
mutation invalidateInviteCode($code: String!) {
invalidateInviteCode(code: $code) {
code
createdAt
generatedBy {
id
name
avatar {
url
}
}
redeemedBy {
id
name
avatar {
url
}
}
expiresAt
comment
invitedTo {
id
groupType
name
about
avatar {
url
}
}
isValid
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const redeemInviteCode = gql`
mutation redeemInviteCode($code: String!) {
redeemInviteCode(code: $code)
}
`

View File

@ -0,0 +1,49 @@
import gql from 'graphql-tag'
export const unauthenticatedValidateInviteCode = gql`
query validateInviteCode($code: String!) {
validateInviteCode(code: $code) {
code
invitedTo {
groupType
name
about
avatar {
url
}
}
generatedBy {
name
avatar {
url
}
}
isValid
}
}
`
export const authenticatedValidateInviteCode = gql`
query validateInviteCode($code: String!) {
validateInviteCode(code: $code) {
code
invitedTo {
id
groupType
name
about
avatar {
url
}
}
generatedBy {
id
name
avatar {
url
}
}
isValid
}
}
`

View File

@ -1,41 +1,43 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import createServer from '@src/server'
import createServer, { getContext } from '@src/server'
const driver = getDriver()
const instance = getNeode()
let regularUser, administrator, moderator, badge, verification
let authenticatedUser, regularUser, administrator, moderator, badge, verification, query, mutate
const database = databaseContext()
let server: ApolloServer
let authenticatedUser
let query, mutate
beforeAll(async () => {
await cleanDatabase()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
mutate = createTestClientResult.mutate
})
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
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()
await driver.close()
})
beforeEach(async () => {
regularUser = await Factory.build(
'user',
@ -83,7 +85,6 @@ describe('Badges', () => {
})
})
// 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()
})
@ -122,7 +123,7 @@ describe('Badges', () => {
})
describe('authenticated as moderator', () => {
beforeEach(async () => {
beforeEach(() => {
authenticatedUser = moderator.toJson()
})
@ -322,7 +323,7 @@ describe('Badges', () => {
})
describe('authenticated as moderator', () => {
beforeEach(async () => {
beforeEach(() => {
authenticatedUser = moderator.toJson()
})

View File

@ -10,8 +10,8 @@ import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
import { groupMembersQuery } from '@graphql/queries/groupMembersQuery'
import { groupQuery } from '@graphql/queries/groupQuery'
import { Group as groupQuery } from '@graphql/queries/Group'
import { GroupMembers as groupMembersQuery } from '@graphql/queries/GroupMembers'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation'
import { removeUserFromGroupMutation } from '@graphql/queries/removeUserFromGroupMutation'
@ -423,7 +423,7 @@ describe('in mode', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const { errors } = await query({ query: groupQuery(), variables: {} })
const { errors } = await query({ query: groupQuery, variables: {} })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -541,7 +541,7 @@ describe('in mode', () => {
describe('in general finds only listed groups no hidden groups where user is none or pending member', () => {
describe('without any filters', () => {
it('finds all listed groups including the set descriptionExcerpts and locations', async () => {
const result = await query({ query: groupQuery(), variables: {} })
const result = await query({ query: groupQuery, variables: {} })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@ -586,9 +586,7 @@ describe('in mode', () => {
})
it('has set categories', async () => {
await expect(
query({ query: groupQuery(), variables: {} }),
).resolves.toMatchObject({
await expect(query({ query: groupQuery, variables: {} })).resolves.toMatchObject({
data: {
Group: expect.arrayContaining([
expect.objectContaining({
@ -622,7 +620,7 @@ describe('in mode', () => {
describe('with given id', () => {
describe("id = 'my-group'", () => {
it('finds only the listed group with this id', async () => {
const result = await query({ query: groupQuery(), variables: { id: 'my-group' } })
const result = await query({ query: groupQuery, variables: { id: 'my-group' } })
expect(result).toMatchObject({
data: {
Group: [
@ -642,7 +640,7 @@ describe('in mode', () => {
describe("id = 'third-hidden-group'", () => {
it("finds only the hidden group where I'm 'usual' member", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { id: 'third-hidden-group' },
})
expect(result).toMatchObject({
@ -664,7 +662,7 @@ describe('in mode', () => {
describe("id = 'second-hidden-group'", () => {
it("finds no hidden group where I'm 'pending' member", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { id: 'second-hidden-group' },
})
expect(result.data?.Group.length).toBe(0)
@ -674,7 +672,7 @@ describe('in mode', () => {
describe("id = 'hidden-group'", () => {
it("finds no hidden group where I'm not(!) a member at all", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { id: 'hidden-group' },
})
expect(result.data?.Group.length).toBe(0)
@ -686,7 +684,7 @@ describe('in mode', () => {
describe("slug = 'the-best-group'", () => {
it('finds only the listed group with this slug', async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { slug: 'the-best-group' },
})
expect(result).toMatchObject({
@ -708,7 +706,7 @@ describe('in mode', () => {
describe("slug = 'third-investigative-journalism-group'", () => {
it("finds only the hidden group where I'm 'usual' member", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { slug: 'third-investigative-journalism-group' },
})
expect(result).toMatchObject({
@ -730,7 +728,7 @@ describe('in mode', () => {
describe("slug = 'second-investigative-journalism-group'", () => {
it("finds no hidden group where I'm 'pending' member", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { slug: 'second-investigative-journalism-group' },
})
expect(result.data?.Group.length).toBe(0)
@ -740,7 +738,7 @@ describe('in mode', () => {
describe("slug = 'investigative-journalism-group'", () => {
it("finds no hidden group where I'm not(!) a member at all", async () => {
const result = await query({
query: groupQuery(),
query: groupQuery,
variables: { slug: 'investigative-journalism-group' },
})
expect(result.data?.Group.length).toBe(0)
@ -750,7 +748,7 @@ describe('in mode', () => {
describe('isMember = true', () => {
it('finds only listed groups where user is member', async () => {
const result = await query({ query: groupQuery(), variables: { isMember: true } })
const result = await query({ query: groupQuery, variables: { isMember: true } })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@ -774,7 +772,7 @@ describe('in mode', () => {
describe('isMember = false', () => {
it('finds only listed groups where user is not(!) member', async () => {
const result = await query({ query: groupQuery(), variables: { isMember: false } })
const result = await query({ query: groupQuery, variables: { isMember: false } })
expect(result).toMatchObject({
data: {
Group: expect.arrayContaining([
@ -1039,7 +1037,7 @@ describe('in mode', () => {
variables = {
id: 'not-existing-group',
}
const { errors } = await query({ query: groupMembersQuery(), variables })
const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -1212,7 +1210,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1245,7 +1243,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1278,7 +1276,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1321,7 +1319,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1354,7 +1352,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1386,7 +1384,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
const { errors } = await query({ query: groupMembersQuery(), variables })
const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -1397,7 +1395,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
const { errors } = await query({ query: groupMembersQuery(), variables })
const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -1419,7 +1417,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1456,7 +1454,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1493,7 +1491,7 @@ describe('in mode', () => {
it('finds all members', async () => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables,
})
expect(result).toMatchObject({
@ -1529,7 +1527,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
const { errors } = await query({ query: groupMembersQuery(), variables })
const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -1540,7 +1538,7 @@ describe('in mode', () => {
})
it('throws authorization error', async () => {
const { errors } = await query({ query: groupMembersQuery(), variables })
const { errors } = await query({ query: groupMembersQuery, variables })
expect(errors![0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -2418,7 +2416,7 @@ describe('in mode', () => {
describe('here "closed-group" for example', () => {
const memberInGroup = async (userId, groupId) => {
const result = await query({
query: groupMembersQuery(),
query: groupMembersQuery,
variables: {
id: groupId,
},

View File

@ -436,6 +436,24 @@ export default {
},
},
Group: {
inviteCodes: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Group!')
}
return (
await context.database.query({
query: `
MATCH (user:User {id: $user.id})-[:GENERATED]->(inviteCodes:InviteCode)-[:INVITES_TO]->(g:Group {id: $parent.id})
RETURN inviteCodes {.*}
ORDER BY inviteCodes.createdAt ASC
`,
variables: {
user: context.user,
parent,
},
})
).records.map((r) => r.get('inviteCodes'))
},
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
@ -451,6 +469,18 @@ export default {
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
},
}),
name: async (parent, _args, context: Context, _resolveInfo) => {
if (!context.user) {
return parent.groupType === 'hidden' ? '' : parent.name
}
return parent.name
},
about: async (parent, _args, context: Context, _resolveInfo) => {
if (!context.user) {
return parent.groupType === 'hidden' ? '' : parent.about
}
return parent.about
},
},
}

View File

@ -1,13 +0,0 @@
import registrationConstants from '@constants/registrationBranded'
export default function generateInviteCode() {
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
return Array.from(
{ length: registrationConstants.INVITE_CODE_LENGTH },
(n: number = 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('')
}

File diff suppressed because it is too large Load Diff

View File

@ -1,136 +1,294 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import generateInviteCode from './helpers/generateInviteCode'
import Resolver from './helpers/Resolver'
import { validateInviteCode } from './transactions/inviteCodes'
import CONFIG from '@config/index'
import registrationConstants from '@constants/registrationBranded'
// eslint-disable-next-line import/no-cycle
import { Context } from '@src/server'
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,
import Resolver from './helpers/Resolver'
export const generateInviteCode = () => {
// 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z])
return Array.from(
{ length: registrationConstants.INVITE_CODE_LENGTH },
(n: number = 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('')
}
const uniqueInviteCode = async (context: Context, code: string) => {
return (
(
await context.database.query({
query: `MATCH (inviteCode:InviteCode { code: toUpper($code) })
WHERE inviteCode.expiresAt IS NULL
OR inviteCode.expiresAt >= datetime()
RETURN toString(count(inviteCode)) AS count`,
variables: { code },
})
).records[0].get('count') === '0'
)
}
export const validateInviteCode = async (context: Context, inviteCode) => {
const result = (
await context.database.query({
query: `
OPTIONAL MATCH (inviteCode:InviteCode { code: toUpper($inviteCode) })
RETURN
CASE
WHEN inviteCode IS NULL THEN false
WHEN inviteCode.expiresAt IS NULL THEN true
WHEN datetime(inviteCode.expiresAt) >= datetime() THEN true
ELSE false END AS result
`,
variables: { inviteCode },
})
return parseInt(String(result.records[0].get('count'))) === 0
})
).records
return result[0].get('result') === true
}
export const redeemInviteCode = async (context: Context, code, newUser = false) => {
const result = (
await context.database.query({
query: `
MATCH (inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host:User)
OPTIONAL MATCH (inviteCode)-[:INVITES_TO]->(group:Group)
WHERE inviteCode.expiresAt IS NULL
OR datetime(inviteCode.expiresAt) >= datetime()
RETURN inviteCode {.*}, group {.*}, host {.*}`,
variables: { code },
})
).records
if (result.length !== 1) {
return false
}
const inviteCode = result[0].get('inviteCode')
const group = result[0].get('group')
const host = result[0].get('host')
if (!inviteCode || !host) {
return false
}
// self
if (host.id === context.user.id) {
return true
}
// Personal Invite Link
if (!group) {
// We redeemed this link while having an account, hence we do nothing, but return true
if (!newUser) {
return true
}
await context.database.write({
query: `
MATCH (user:User {id: $user.id}), (inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host:User)
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`,
variables: { user: context.user, code },
})
// Group Invite Link
} else {
const role = ['closed', 'hidden'].includes(group.groupType as string) ? 'pending' : 'usual'
const optionalInvited = newUser
? 'MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)'
: ''
await context.database.write({
query: `
MATCH (user:User {id: $user.id}), (group:Group)<-[:INVITES_TO]-(inviteCode:InviteCode {code: toUpper($code)})<-[:GENERATED]-(host:User)
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
${optionalInvited}
MERGE (user)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
membership.role = $role
`,
variables: { user: context.user, code, role },
})
}
return true
}
export default {
Query: {
getInviteCode: 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)
WHERE ic.expiresAt IS NULL
OR datetime(ic.expiresAt) >= datetime()
RETURN properties(ic) AS inviteCodes`,
{
userId,
},
)
return result.records.map((record) => record.get('inviteCodes'))
})
try {
const inviteCode = await readTxResultPromise
if (inviteCode && inviteCode.length > 0) return inviteCode[0]
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: null,
},
)
return result.records.map((record) => record.get('inviteCode').properties)
validateInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
const result = (
await context.database.query({
query: `
MATCH (inviteCode:InviteCode { code: toUpper($args.code) })
WHERE inviteCode.expiresAt IS NULL
OR datetime(inviteCode.expiresAt) >= datetime()
RETURN inviteCode {.*}`,
variables: { args },
})
const txResult = await writeTxResultPromise
return txResult[0]
} finally {
session.close()
).records
if (result.length !== 1) {
return null
}
},
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
const session = context.driver.session()
if (!code) return false
return validateInviteCode(session, code)
return result[0].get('inviteCode')
},
},
Mutation: {
GenerateInviteCode: async (_parent, args, context, _resolveInfo) => {
const {
user: { id: userId },
} = context
const session = context.driver.session()
generatePersonalInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
const userInviteCodeAmount = (
await context.database.query({
query: `
MATCH (inviteCode:InviteCode)<-[:GENERATED]-(user:User {id: $user.id})
WHERE NOT (inviteCode)-[:INVITES_TO]-(:Group)
AND (inviteCode.expiresAt IS NULL OR inviteCode.expiresAt >= datetime())
RETURN toString(count(inviteCode)) as count
`,
variables: { user: context.user },
})
).records[0].get('count')
if (parseInt(userInviteCodeAmount as string) >= CONFIG.INVITE_CODES_PERSONAL_PER_USER) {
throw new Error('You have reached the maximum of Invite Codes you can generate')
}
let code = generateInviteCode()
while (!(await uniqueInviteCode(session, code))) {
while (!(await uniqueInviteCode(context, 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 (
await context.database.write({
// We delete a potential old invite code if there is a collision on an expired code
query: `
MATCH (user:User {id: $user.id})
OPTIONAL MATCH (oldInviteCode:InviteCode { code: toUpper($code) })
DETACH DELETE oldInviteCode
MERGE (user)-[:GENERATED]->(inviteCode:InviteCode { code: toUpper($code)})
ON CREATE SET
inviteCode.createdAt = toString(datetime()),
inviteCode.expiresAt = $args.expiresAt,
inviteCode.comment = $args.comment
RETURN inviteCode {.*}`,
variables: { user: context.user, code, args },
})
).records[0].get('inviteCode')
},
generateGroupInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
const userInviteCodeAmount = (
await context.database.query({
query: `
MATCH (:Group {id: $args.groupId})<-[:INVITES_TO]-(inviteCode:InviteCode)<-[:GENERATED]-(user:User {id: $user.id})
WHERE inviteCode.expiresAt IS NULL
OR inviteCode.expiresAt >= datetime()
RETURN toString(count(inviteCode)) as count
`,
variables: { user: context.user, args },
})
).records[0].get('count')
if (parseInt(userInviteCodeAmount as string) >= CONFIG.INVITE_CODES_GROUP_PER_USER) {
throw new Error(
'You have reached the maximum of Invite Codes you can generate for this group',
)
return result.records.map((record) => record.get('inviteCode').properties)
})
try {
const txResult = await writeTxResultPromise
return txResult[0]
} finally {
session.close()
}
let code = generateInviteCode()
while (!(await uniqueInviteCode(context, code))) {
code = generateInviteCode()
}
const inviteCode = (
await context.database.write({
query: `
MATCH
(user:User {id: $user.id})-[membership:MEMBER_OF]->(group:Group {id: $args.groupId})
WHERE NOT membership.role = 'pending'
OPTIONAL MATCH (oldInviteCode:InviteCode { code: toUpper($code) })
DETACH DELETE oldInviteCode
MERGE (user)-[:GENERATED]->(inviteCode:InviteCode { code: toUpper($code) })-[:INVITES_TO]->(group)
ON CREATE SET
inviteCode.createdAt = toString(datetime()),
inviteCode.expiresAt = $args.expiresAt,
inviteCode.comment = $args.comment
RETURN inviteCode {.*}`,
variables: { user: context.user, code, args },
})
).records
if (inviteCode.length !== 1) {
// Not a member
throw new Error('Not Authorized!')
}
return inviteCode[0].get('inviteCode')
},
invalidateInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
const result = (
await context.database.write({
query: `
MATCH (user:User {id: $user.id})-[:GENERATED]-(inviteCode:InviteCode {code: toUpper($args.code)})
SET inviteCode.expiresAt = toString(datetime())
RETURN inviteCode {.*}`,
variables: { args, user: context.user },
})
).records
if (result.length !== 1) {
// Link not generated by this user or does not exist
throw new Error('Not Authorized!')
}
return result[0].get('inviteCode')
},
redeemInviteCode: async (_parent, args, context: Context, _resolveInfo) => {
return redeemInviteCode(context, args.code)
},
},
InviteCode: {
invitedTo: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.code) {
return null
}
const result = (
await context.database.query({
query: `
MATCH (inviteCode:InviteCode {code: $parent.code})-[:INVITES_TO]->(group:Group)
RETURN group {.*}
`,
variables: { parent },
})
).records
if (result.length !== 1) {
return null
}
return result[0].get('group')
},
isValid: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.code) {
return false
}
return validateInviteCode(context, parent.code)
},
...Resolver('InviteCode', {
idAttribute: 'code',
undefinedToNull: ['expiresAt'],
undefinedToNull: ['expiresAt', 'comment'],
count: {
redeemedByCount: '<-[:REDEEMED]-(related:User)',
},
hasOne: {
generatedBy: '<-[:GENERATED]-(related:User)',
},

View File

@ -24,6 +24,9 @@ export default {
],
}),
distanceToMe: async (parent, _params, context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Location!')
}
const session = context.driver.session()
const query = session.readTransaction(async (transaction) => {

View File

@ -1,55 +1,51 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import Image from '@db/models/Image'
import { getNeode, getDriver } from '@db/neo4j'
import { createPostMutation } from '@graphql/queries/createPostMutation'
import createServer from '@src/server'
import createServer, { getContext } from '@src/server'
CONFIG.CATEGORIES_ACTIVE = true
const driver = getDriver()
const neode = getNeode()
let query
let mutate
let authenticatedUser
let user
const categoryIds = ['cat9', 'cat4', 'cat15']
let variables
const database = databaseContext()
let server: ApolloServer
let authenticatedUser
let query, mutate
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
mutate = createTestClientResult.mutate
query = createTestClientResult.query
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
const categoryIds = ['cat9', 'cat4', 'cat15']
let variables
beforeEach(async () => {
variables = {}
user = await Factory.build(
@ -64,22 +60,22 @@ beforeEach(async () => {
},
)
await Promise.all([
neode.create('Category', {
database.neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
neode.create('Category', {
database.neode.create('Category', {
id: 'cat4',
name: 'Environment & Nature',
icon: 'tree',
}),
neode.create('Category', {
database.neode.create('Category', {
id: 'cat15',
name: 'Consumption & Sustainability',
icon: 'shopping-cart',
}),
neode.create('Category', {
database.neode.create('Category', {
id: 'cat27',
name: 'Animal Protection',
icon: 'paw',
@ -88,7 +84,6 @@ beforeEach(async () => {
authenticatedUser = null
})
// 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()
})
@ -233,7 +228,6 @@ describe('Post', () => {
Post(filter: $filter) {
id
author {
id
name
}
}
@ -249,7 +243,7 @@ describe('Post', () => {
Post: [
{
id: 'post-by-followed-user',
author: { id: 'followed-by-me', name: 'Followed User' },
author: { name: 'Followed User' },
},
],
},
@ -976,11 +970,11 @@ describe('UpdatePost', () => {
})
it('updates the image', async () => {
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeTruthy()
})
})
@ -990,9 +984,9 @@ describe('UpdatePost', () => {
variables = { ...variables, image: null }
})
it('deletes the image', async () => {
await expect(neode.all('Image')).resolves.toHaveLength(6)
await expect(database.neode.all('Image')).resolves.toHaveLength(6)
await mutate({ mutation: updatePostMutation, variables })
await expect(neode.all('Image')).resolves.toHaveLength(5)
await expect(database.neode.all('Image')).resolves.toHaveLength(5)
})
})
@ -1002,11 +996,11 @@ describe('UpdatePost', () => {
})
it('keeps the image unchanged', async () => {
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: updatePostMutation, variables })
await expect(
neode.first<typeof Image>('Image', { sensitive: true }, undefined),
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
})
})
@ -1253,18 +1247,18 @@ describe('pin posts', () => {
it('removes previous `pinned` attribute', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post'
pinnedPost = await neode.cypher(cypher, {})
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await neode.cypher(cypher, {})
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await neode.cypher(
pinnedPost = await database.neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
{},
)
@ -1593,7 +1587,7 @@ describe('emotions', () => {
`
beforeEach(async () => {
author = await neode.create('User', { id: 'u257' })
author = await database.neode.create('User', { id: 'u257' })
postToEmote = await Factory.build(
'post',
{
@ -1628,7 +1622,7 @@ describe('emotions', () => {
`
let postsEmotionsQueryVariables
beforeEach(async () => {
beforeEach(() => {
postsEmotionsQueryVariables = { id: 'p1376' }
})

View File

@ -1,49 +1,48 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import EmailAddress from '@db/models/EmailAddress'
import User from '@db/models/User'
import { getDriver, getNeode } from '@db/neo4j'
import createServer from '@src/server'
import createServer, { getContext } from '@src/server'
const neode = getNeode()
let mutate
let authenticatedUser
let variables
const driver = getDriver()
const database = databaseContext()
let server: ApolloServer
let authenticatedUser
let mutate
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
mutate = createTestClient(server).mutate
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
mutate = createTestClientResult.mutate
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
beforeEach(async () => {
beforeEach(() => {
variables = {}
})
// 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()
})
@ -98,7 +97,7 @@ describe('Signup', () => {
describe('creates a EmailAddress node', () => {
it('with `createdAt` attribute', async () => {
await mutate({ mutation, variables })
const emailAddress = await neode.first<typeof EmailAddress>(
const emailAddress = await database.neode.first<typeof EmailAddress>(
'EmailAddress',
{ email: 'someuser@example.org' },
undefined,
@ -112,7 +111,7 @@ describe('Signup', () => {
it('with a cryptographic `nonce`', async () => {
await mutate({ mutation, variables })
const emailAddress = await neode.first<typeof EmailAddress>(
const emailAddress = await database.neode.first<typeof EmailAddress>(
'EmailAddress',
{ email: 'someuser@example.org' },
undefined,
@ -153,12 +152,12 @@ describe('Signup', () => {
it('creates no additional `EmailAddress` node', async () => {
// admin account and the already existing user
await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
await expect(database.neode.all('EmailAddress')).resolves.toHaveLength(2)
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: { Signup: { email: 'someuser@example.org' } },
errors: undefined,
})
await expect(neode.all('EmailAddress')).resolves.toHaveLength(2)
await expect(database.neode.all('EmailAddress')).resolves.toHaveLength(2)
})
})
})
@ -194,7 +193,7 @@ describe('SignupVerification', () => {
}
`
describe('given valid password and email', () => {
beforeEach(async () => {
beforeEach(() => {
variables = {
...variables,
nonce: '12345',
@ -207,7 +206,7 @@ describe('SignupVerification', () => {
})
describe('unauthenticated', () => {
beforeEach(async () => {
beforeEach(() => {
authenticatedUser = null
})
@ -215,8 +214,8 @@ describe('SignupVerification', () => {
beforeEach(async () => {
const { email, nonce } = variables
const [emailAddress, user] = await Promise.all([
neode.model('EmailAddress').create({ email, nonce }),
neode
database.neode.model('EmailAddress').create({ email, nonce }),
database.neode
.model('User')
.create({ name: 'Somebody', password: '1234', email: 'john@example.org' }),
])
@ -242,7 +241,7 @@ describe('SignupVerification', () => {
email: 'john@example.org',
nonce: '12345',
}
await neode.model('EmailAddress').create(args)
await database.neode.model('EmailAddress').create(args)
})
describe('sending a valid nonce', () => {
@ -258,7 +257,7 @@ describe('SignupVerification', () => {
it('sets `verifiedAt` attribute of EmailAddress', async () => {
await mutate({ mutation, variables })
const email = await neode.first(
const email = await database.neode.first(
'EmailAddress',
{ email: 'john@example.org' },
undefined,
@ -276,14 +275,18 @@ describe('SignupVerification', () => {
RETURN email
`
await mutate({ mutation, variables })
const { records: emails } = await neode.cypher(cypher, { name: 'John Doe' })
const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
it('sets `about` attribute of User', async () => {
variables = { ...variables, about: 'Find this description in the user profile' }
await mutate({ mutation, variables })
const user = await neode.first<typeof User>('User', { name: 'John Doe' }, undefined)
const user = await database.neode.first<typeof User>(
'User',
{ name: 'John Doe' },
undefined,
)
await expect(user.toJson()).resolves.toMatchObject({
about: 'Find this description in the user profile',
})
@ -306,7 +309,7 @@ describe('SignupVerification', () => {
RETURN email
`
await mutate({ mutation, variables })
const { records: emails } = await neode.cypher(cypher, { name: 'John Doe' })
const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})

View File

@ -7,10 +7,12 @@ import { UserInputError } from 'apollo-server'
import { hash } from 'bcryptjs'
import { getNeode } from '@db/neo4j'
import { Context } from '@src/server'
import existingEmailAddress from './helpers/existingEmailAddress'
import generateNonce from './helpers/generateNonce'
import normalizeEmail from './helpers/normalizeEmail'
import { redeemInviteCode } from './inviteCodes'
const neode = getNeode()
@ -33,7 +35,7 @@ export default {
throw new UserInputError(e.message)
}
},
SignupVerification: async (_parent, args, context) => {
SignupVerification: async (_parent, args, context: Context) => {
const { termsAndConditionsAgreedVersion } = args
const regEx = /^[0-9]+\.[0-9]+\.[0-9]+$/g
if (!regEx.test(termsAndConditionsAgreedVersion)) {
@ -52,14 +54,45 @@ export default {
const { driver } = context
const session = driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createUserTransactionResponse = await transaction.run(signupCypher(inviteCode), {
args,
nonce,
email,
inviteCode,
})
const createUserTransactionResponse = await transaction.run(
`
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
CREATE (user:User)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
SET user += $args
SET user.id = randomUUID()
SET user.role = 'user'
SET user.createdAt = toString(datetime())
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`,
{
args,
nonce,
email,
inviteCode,
},
)
const [user] = createUserTransactionResponse.records.map((record) => record.get('user'))
if (!user) throw new UserInputError('Invalid email or nonce')
// To allow redeeming and return an User object we require a User in the context
context.user = user
// join Group via invite Code
await redeemInviteCode(context, inviteCode, true)
return user
})
try {
@ -70,51 +103,8 @@ export default {
throw new UserInputError('User with this slug already exists!')
throw new UserInputError(e.message)
} finally {
session.close()
await session.close()
}
},
},
}
const signupCypher = (inviteCode) => {
let optionalMatch = ''
let optionalMerge = ''
if (inviteCode) {
optionalMatch = `
OPTIONAL MATCH
(inviteCode:InviteCode {code: $inviteCode})<-[:GENERATED]-(host:User)
`
optionalMerge = `
MERGE (user)-[:REDEEMED { createdAt: toString(datetime()) }]->(inviteCode)
MERGE (host)-[:INVITED { createdAt: toString(datetime()) }]->(user)
MERGE (user)-[:FOLLOWS { createdAt: toString(datetime()) }]->(host)
MERGE (host)-[:FOLLOWS { createdAt: toString(datetime()) }]->(user)
`
}
const cypher = `
MATCH (email:EmailAddress {nonce: $nonce, email: $email})
WHERE NOT (email)-[:BELONGS_TO]->()
${optionalMatch}
CREATE (user:User)
MERGE (user)-[:PRIMARY_EMAIL]->(email)
MERGE (user)<-[:BELONGS_TO]-(email)
${optionalMerge}
SET user += $args
SET user.id = randomUUID()
SET user.role = 'user'
SET user.createdAt = toString(datetime())
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
WHERE NOT group.groupType = 'public'
WITH user, collect(post) AS invisiblePosts
FOREACH (invisiblePost IN invisiblePosts |
MERGE (user)-[:CANNOT_SEE]->(invisiblePost)
)
RETURN user {.*}
`
return cypher
}

View File

@ -1,26 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export async function validateInviteCode(session, inviteCode) {
const readTxResultPromise = session.readTransaction(async (txc) => {
const result = await txc.run(
`MATCH (ic:InviteCode { code: toUpper($inviteCode) })
RETURN
CASE
WHEN ic.expiresAt IS NULL THEN true
WHEN datetime(ic.expiresAt) >= datetime() THEN true
ELSE false END AS result`,
{
inviteCode,
},
)
return result.records.map((record) => record.get('result'))
})
try {
const txResult = await readTxResultPromise
return !!txResult[0]
} finally {
session.close()
}
}

View File

@ -10,6 +10,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import { getNeode } from '@db/neo4j'
import { Context } from '@src/server'
import { defaultTrophyBadge, defaultVerificationBadge } from './badges'
import Resolver from './helpers/Resolver'
@ -467,6 +468,23 @@ export default {
},
},
User: {
inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => {
const {
user: { id: userId },
} = context
return (
await context.database.query({
query: `
MATCH (user:User {id: $userId})-[:GENERATED]->(inviteCodes:InviteCode)
WHERE NOT (inviteCodes)-[:INVITES_TO]->(:Group)
RETURN inviteCodes {.*}
ORDER BY inviteCodes.createdAt ASC
`,
variables: { userId },
})
).records
},
emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => {
return [
{

View File

@ -43,6 +43,9 @@ type Group {
posts: [Post] @relation(name: "IN", direction: "IN")
isMutedByMe: Boolean! @cypher(statement: "MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )")
"inviteCodes to this group the current user has generated"
inviteCodes: [InviteCode]! @neo4j_ignore
}

View File

@ -3,16 +3,23 @@ type InviteCode {
createdAt: String!
generatedBy: User @relation(name: "GENERATED", direction: "IN")
redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN")
redeemedByCount: Int! @cypher(statement: "MATCH (this)<-[:REDEEMED]-(related:User)")
expiresAt: String
}
comment: String
invitedTo: Group @neo4j_ignore
# invitedFrom: User! @neo4j_ignore # -> see generatedBy
type Mutation {
GenerateInviteCode(expiresAt: String = null): InviteCode
isValid: Boolean! @neo4j_ignore
}
type Query {
MyInviteCodes: [InviteCode]
isValidInviteCode(code: ID!): Boolean
getInviteCode: InviteCode
validateInviteCode(code: String!): InviteCode
}
type Mutation {
generatePersonalInviteCode(expiresAt: String = null, comment: String = null): InviteCode!
generateGroupInviteCode(groupId: ID!, expiresAt: String = null, comment: String = null): InviteCode!
invalidateInviteCode(code: String!): InviteCode
redeemInviteCode(code: String!): Boolean!
}

View File

@ -72,9 +72,6 @@ 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: """
@ -125,6 +122,7 @@ type User {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
# Badges
badgeVerification: Badge! @neo4j_ignore
badgeTrophies: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgeTrophiesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
@ -132,6 +130,11 @@ type User {
badgeTrophiesUnused: [Badge]! @neo4j_ignore
badgeTrophiesUnusedCount: Int! @neo4j_ignore
"personal inviteCodes the user has generated"
inviteCodes: [InviteCode]! @neo4j_ignore
# inviteCodes: [InviteCode]! @relation(name: "GENERATED", direction: "OUT")
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
emotions: [EMOTED]
activeCategories: [String] @cypher(

View File

@ -15,6 +15,7 @@ import languages from './languages/languages'
import login from './login/loginMiddleware'
import notifications from './notifications/notificationsMiddleware'
import orderBy from './orderByMiddleware'
// eslint-disable-next-line import/no-cycle
import permissions from './permissionsMiddleware'
import sentry from './sentryMiddleware'
import sluggify from './sluggifyMiddleware'

View File

@ -1,42 +1,45 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import CONFIG from '@config/index'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { getDriver, getNeode } from '@db/neo4j'
import createServer from '@src/server'
import createServer, { getContext } from '@src/server'
const instance = getNeode()
const driver = getDriver()
let variables
let owner, anotherRegularUser, administrator, moderator
let query, mutate, variables
let authenticatedUser, owner, anotherRegularUser, administrator, moderator
const database = databaseContext()
let server: ApolloServer
let authenticatedUser
let query, mutate
beforeAll(async () => {
await cleanDatabase()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
query = createTestClientResult.query
mutate = createTestClientResult.mutate
})
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
describe('authorization', () => {
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => ({
driver,
instance,
user: authenticatedUser,
}),
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
})
// 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()
})
@ -109,7 +112,7 @@ describe('authorization', () => {
query({ query: userQuery, variables: { name: 'Owner' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { User: [null] },
data: { User: null },
})
})
})
@ -242,7 +245,7 @@ describe('authorization', () => {
})
describe('as anyone', () => {
beforeEach(async () => {
beforeEach(() => {
authenticatedUser = null
})
@ -267,7 +270,7 @@ describe('authorization', () => {
})
describe('as anyone with valid invite code', () => {
beforeEach(async () => {
beforeEach(() => {
variables = {
email: 'some@email.org',
inviteCode: 'ABCDEF',
@ -287,7 +290,7 @@ describe('authorization', () => {
})
describe('as anyone without valid invite', () => {
beforeEach(async () => {
beforeEach(() => {
variables = {
email: 'some@email.org',
inviteCode: 'no valid invite code',

View File

@ -9,7 +9,9 @@ import { rule, shield, deny, allow, or, and } from 'graphql-shield'
import CONFIG from '@config/index'
import SocialMedia from '@db/models/SocialMedia'
import { getNeode } from '@db/neo4j'
import { validateInviteCode } from '@graphql/resolvers/transactions/inviteCodes'
// eslint-disable-next-line import/no-cycle
import { validateInviteCode } from '@graphql/resolvers/inviteCodes'
import { Context } from '@src/server'
const debug = !!CONFIG.DEBUG
const allowExternalErrors = true
@ -370,11 +372,28 @@ const noEmailFilter = rule({
const publicRegistration = rule()(() => CONFIG.PUBLIC_REGISTRATION)
const inviteRegistration = rule()(async (_parent, args, { _user, driver }) => {
const inviteRegistration = rule()(async (_parent, args, context: Context) => {
if (!CONFIG.INVITE_REGISTRATION) return false
const { inviteCode } = args
const session = driver.session()
return validateInviteCode(session, inviteCode)
return validateInviteCode(context, inviteCode)
})
const isAllowedToGenerateGroupInviteCode = rule({
cache: 'no_cache',
})(async (_parent, args, context: Context) => {
if (!context.user) return false
return !!(
await context.database.query({
query: `
MATCH (user:User{id: user.id})-[membership:MEMBER_OF]->(group:Group {id: $args.groupId})
WHERE (group.type IN ['closed','hidden'] AND membership.role IN ['admin', 'owner'])
OR (NOT group.type IN ['closed','hidden'] AND NOT membership.role = 'pending')
RETURN count(group) as count
`,
variables: { user: context.user, args },
})
).records[0].get('count')
})
// Permissions
@ -399,7 +418,7 @@ export default shield(
Post: allow,
profilePagePosts: allow,
Comment: allow,
User: or(noEmailFilter, isAdmin),
User: and(isAuthenticated, or(noEmailFilter, isAdmin)),
Badge: allow,
PostsEmotionsCountByEmotion: allow,
PostsEmotionsByCurrentUser: isAuthenticated,
@ -408,15 +427,15 @@ export default shield(
notifications: isAuthenticated,
Donations: isAuthenticated,
userData: isAuthenticated,
MyInviteCodes: isAuthenticated,
isValidInviteCode: allow,
VerifyNonce: allow,
queryLocations: isAuthenticated,
availableRoles: isAdmin,
getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated,
Message: isAuthenticated,
UnreadRooms: isAuthenticated,
// Invite Code
validateInviteCode: allow,
},
Mutation: {
'*': deny,
@ -465,7 +484,13 @@ export default shield(
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
GenerateInviteCode: isAuthenticated,
// InviteCode
generatePersonalInviteCode: isAuthenticated,
generateGroupInviteCode: isAllowedToGenerateGroupInviteCode,
invalidateInviteCode: isAuthenticated,
redeemInviteCode: isAuthenticated,
switchUserRole: isAdmin,
markTeaserAsViewed: allow,
saveCategorySettings: isAuthenticated,
@ -480,8 +505,27 @@ export default shield(
resetTrophyBadgesSelected: isAuthenticated,
},
User: {
'*': isAuthenticated,
name: allow,
avatar: allow,
email: or(isMyOwn, isAdmin),
emailNotificationSettings: isMyOwn,
inviteCodes: isMyOwn,
},
Group: {
'*': isAuthenticated, // TODO - only those who are allowed to see the group
avatar: allow,
name: allow,
about: allow,
groupType: allow,
},
InviteCode: {
'*': allow,
redeemedBy: isAuthenticated, // TODO only for self generated, must be done in resolver
redeemedByCount: isAuthenticated, // TODO only for self generated, must be done in resolver
createdAt: isAuthenticated, // TODO only for self generated, must be done in resolver
expiresAt: isAuthenticated, // TODO only for self generated, must be done in resolver
comment: isAuthenticated, // TODO only for self generated, must be done in resolver
},
Location: {
distanceToMe: isAuthenticated,

View File

@ -2,47 +2,46 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing'
import databaseContext from '@context/database'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
import { createPostMutation } from '@graphql/queries/createPostMutation'
import { signupVerificationMutation } from '@graphql/queries/signupVerificationMutation'
import { updateGroupMutation } from '@graphql/queries/updateGroupMutation'
import createServer from '@src/server'
import createServer, { getContext } from '@src/server'
let authenticatedUser
let variables
const categoryIds = ['cat9']
const driver = getDriver()
const neode = getNeode()
const descriptionAdditional100 =
' 123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789-123456789'
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
const database = databaseContext()
const { mutate } = createTestClient(server)
let server: ApolloServer
let authenticatedUser
let mutate
beforeAll(async () => {
await cleanDatabase()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await
const contextUser = async (_req) => authenticatedUser
const context = getContext({ user: contextUser, database })
server = createServer({ context }).server
const createTestClientResult = createTestClient(server)
mutate = createTestClientResult.mutate
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
beforeEach(async () => {

View File

@ -19,6 +19,7 @@ import pubsubContext from '@context/pubsub'
import CONFIG from './config'
import schema from './graphql/schema'
import decode from './jwt/decode'
// eslint-disable-next-line import/no-cycle
import middleware from './middleware'
const serverDatabase = databaseContext()