mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
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:
parent
e3864b1f9d
commit
3f4d648562
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
|
||||
@ -14,4 +14,10 @@ export default {
|
||||
target: 'User',
|
||||
direction: 'in',
|
||||
},
|
||||
invitesTo: {
|
||||
type: 'relationship',
|
||||
relationship: 'INVITES_TO',
|
||||
target: 'Group',
|
||||
direction: 'out',
|
||||
},
|
||||
}
|
||||
|
||||
36
backend/src/graphql/queries/Group.ts
Normal file
36
backend/src/graphql/queries/Group.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
12
backend/src/graphql/queries/GroupMembers.ts
Normal file
12
backend/src/graphql/queries/GroupMembers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
15
backend/src/graphql/queries/currentUser.ts
Normal file
15
backend/src/graphql/queries/currentUser.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const currentUser = gql`
|
||||
query currentUser {
|
||||
currentUser {
|
||||
following {
|
||||
name
|
||||
}
|
||||
inviteCodes {
|
||||
code
|
||||
redeemedByCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
36
backend/src/graphql/queries/generateGroupInviteCode.ts
Normal file
36
backend/src/graphql/queries/generateGroupInviteCode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
36
backend/src/graphql/queries/generatePersonalInviteCode.ts
Normal file
36
backend/src/graphql/queries/generatePersonalInviteCode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -1,14 +0,0 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const groupMembersQuery = () => {
|
||||
return gql`
|
||||
query ($id: ID!) {
|
||||
GroupMembers(id: $id) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
myRoleInGroup
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
36
backend/src/graphql/queries/invalidateInviteCode.ts
Normal file
36
backend/src/graphql/queries/invalidateInviteCode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
7
backend/src/graphql/queries/redeemInviteCode.ts
Normal file
7
backend/src/graphql/queries/redeemInviteCode.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const redeemInviteCode = gql`
|
||||
mutation redeemInviteCode($code: String!) {
|
||||
redeemInviteCode(code: $code)
|
||||
}
|
||||
`
|
||||
49
backend/src/graphql/queries/validateInviteCode.ts
Normal file
49
backend/src/graphql/queries/validateInviteCode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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)',
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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' }
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 [
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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!
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user