Merge pull request #1139 from Human-Connection/refactor-social-media-backend

Refactor social media backend
This commit is contained in:
mattwr18 2019-08-01 07:39:36 +02:00 committed by GitHub
commit 06c14fcf6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 356 additions and 225 deletions

View File

@ -1,4 +1,7 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j'
const instance = neode()
/*
* TODO: implement
@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
const isAuthenticated = rule({
cache: 'contextual',
})(async (_parent, _args, ctx, _info) => {
return ctx.user !== null
return ctx.user != null
})
const isModerator = rule()(async (parent, args, { user }, info) => {
@ -30,6 +33,14 @@ const isMyOwn = rule({
return context.user.id === parent.id
})
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
return socialMedia.ownedBy.node.id === user.id
})
const belongsToMe = rule({
cache: 'no_cache',
})(async (_, args, context) => {
@ -163,8 +174,8 @@ const permissions = shield(
DeletePost: isAuthor,
report: isAuthenticated,
CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,

View File

@ -1,23 +1,8 @@
import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const validate = schema => {
return async (resolve, root, args, context, info) => {
const validation = schema.validate(args)
if (validation.error) throw new UserInputError(validation.error)
return resolve(root, args, context, info)
}
}
const socialMediaSchema = Joi.object().keys({
url: Joi.string()
.uri()
.required(),
})
const validateCommentCreation = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args
@ -57,7 +42,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => {
export default {
Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
},

View File

@ -0,0 +1,15 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
url: { type: 'string', uri: true, required: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
ownedBy: {
type: 'relationship',
relationship: 'OWNED_BY',
target: 'User',
direction: 'in',
eager: true,
cascade: 'detach',
},
}

View File

@ -5,4 +5,5 @@ export default {
User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
}

View File

@ -19,6 +19,7 @@ export default applyScalars(
'Notfication',
'Statistics',
'LoggedInUser',
'SocialMedia',
],
// add 'User' here as soon as possible
},
@ -30,6 +31,7 @@ export default applyScalars(
'Notfication',
'Statistics',
'LoggedInUser',
'SocialMedia',
],
// add 'User' here as soon as possible
},

View File

@ -1,5 +1,5 @@
import scrape from './embeds/scraper.js'
import { undefinedToNull } from '../helpers'
import { undefinedToNullResolver } from './helpers/Resolver'
export default {
Query: {
@ -8,7 +8,7 @@ export default {
},
},
Embed: {
...undefinedToNull([
...undefinedToNullResolver([
'type',
'title',
'author',

View File

@ -0,0 +1,75 @@
import { neode } from '../../../bootstrap/neo4j'
export const undefinedToNullResolver = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export default function Resolver(type, options = {}) {
const instance = neode()
const {
idAttribute = 'id',
undefinedToNull = [],
count = {},
hasOne = {},
hasMany = {},
} = options
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const countResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `
MATCH(u:${type} {${idAttribute}: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const hasManyResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
const hasOneResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
const result = {
...undefinedToNullResolver(undefinedToNull),
...countResolver(count),
...hasOneResolver(hasOne),
...hasManyResolver(hasMany),
}
return result
}

View File

@ -1,43 +1,38 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import { neode } from '../../bootstrap/neo4j'
import Resolver from './helpers/Resolver'
const instance = neode()
export default {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
MERGE (socialMedia)<-[:OWNED]-(owner)
RETURN owner`,
{
userId: context.user.id,
socialMediaId: socialMedia.id,
},
)
session.close()
const [user, socialMedia] = await Promise.all([
instance.find('User', context.user.id),
instance.create('SocialMedia', params),
])
await socialMedia.relateTo(user, 'ownedBy')
const response = await socialMedia.toJson()
return socialMedia
},
DeleteSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
return socialMedia
return response
},
UpdateSocialMedia: async (object, params, context, resolveInfo) => {
const session = context.driver.session()
await session.run(
`MATCH (owner: User { id: $userId })-[:OWNED]->(socialMedia: SocialMedia { id: $socialMediaId })
SET socialMedia.url = $socialMediaUrl
RETURN owner`,
{
userId: context.user.id,
socialMediaId: params.id,
socialMediaUrl: params.url,
},
)
session.close()
const socialMedia = await instance.find('SocialMedia', params.id)
await socialMedia.update({ url: params.url })
const response = await socialMedia.toJson()
return params
return response
},
DeleteSocialMedia: async (object, { id }, context, resolveInfo) => {
const socialMedia = await instance.find('SocialMedia', id)
if (!socialMedia) return null
await socialMedia.delete()
return socialMedia.toJson()
},
},
SocialMedia: Resolver('SocialMedia', {
idAttribute: 'url',
hasOne: {
ownedBy: '<-[:OWNED_BY]-(related:User)',
},
}),
}

View File

@ -1,56 +1,59 @@
import { GraphQLClient } from 'graphql-request'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import Factory from '../../seed/factories'
import { host, login, gql } from '../../jest/helpers'
import { gql } from '../../jest/helpers'
import { neode, getDriver } from '../../bootstrap/neo4j'
const driver = getDriver()
const factory = Factory()
const instance = neode()
describe('SocialMedia', () => {
let client, headers, variables, mutation
let socialMediaAction, someUser, ownerNode, owner
const ownerParams = {
email: 'owner@example.com',
email: 'pippi@example.com',
password: '1234',
id: '1234',
name: 'Pippi Langstrumpf',
}
const userParams = {
email: 'someuser@example.com',
email: 'kalle@example.com',
password: 'abcd',
id: 'abcd',
name: 'Kalle Blomqvist',
}
const url = 'https://twitter.com/pippi-langstrumpf'
const newUrl = 'https://twitter.com/bullerby'
const createSocialMediaMutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const updateSocialMediaMutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
const deleteSocialMediaMutation = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
const setUpSocialMedia = async () => {
const socialMediaNode = await instance.create('SocialMedia', { url })
await socialMediaNode.relateTo(ownerNode, 'ownedBy')
return socialMediaNode.toJson()
}
beforeEach(async () => {
await factory.create('User', userParams)
await factory.create('User', ownerParams)
const someUserNode = await instance.create('User', userParams)
someUser = await someUserNode.toJson()
ownerNode = await instance.create('User', ownerParams)
owner = await ownerNode.toJson()
socialMediaAction = async (user, mutation, variables) => {
const { server } = createServer({
context: () => {
return {
user,
driver,
}
},
})
const { mutate } = createTestClient(server)
return mutate({
mutation,
variables,
})
}
})
afterEach(async () => {
@ -58,129 +61,213 @@ describe('SocialMedia', () => {
})
describe('create social media', () => {
let mutation, variables
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url }
mutation = createSocialMediaMutation
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
let user
beforeEach(() => {
user = owner
})
it('creates social media with correct URL', async () => {
await expect(client.request(mutation, variables)).resolves.toEqual(
it('creates social media with the given url', async () => {
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
CreateSocialMedia: {
id: expect.any(String),
url: url,
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
},
},
}),
)
})
it('rejects empty string', async () => {
it('rejects an empty string as url', async () => {
variables = { url: '' }
const result = await socialMediaAction(user, mutation, variables)
await expect(client.request(mutation, variables)).rejects.toThrow(
'"url" is not allowed to be empty',
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" is not allowed to be empty'),
)
})
it('rejects invalid URLs', async () => {
it('rejects invalid urls', async () => {
variables = { url: 'not-a-url' }
const result = await socialMediaAction(user, mutation, variables)
await expect(client.request(createSocialMediaMutation, variables)).rejects.toThrow(
'"url" must be a valid uri',
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" must be a valid uri'),
)
})
})
describe('ownedBy', () => {
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
url
ownedBy {
name
}
}
}
`
})
it('resolves', async () => {
const user = someUser
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
}),
)
})
})
})
describe('update social media', () => {
let mutation, variables
beforeEach(async () => {
headers = await login(ownerParams)
client = new GraphQLClient(host, { headers })
const socialMedia = await setUpSocialMedia()
const { CreateSocialMedia } = await client.request(createSocialMediaMutation, { url })
const { id } = CreateSocialMedia
variables = { url: newUrl, id }
mutation = updateSocialMediaMutation
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as other user', () => {
// TODO: make sure it throws an authorization error
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as owner', () => {
it('updates social media', async () => {
const expected = { UpdateSocialMedia: { ...variables } }
let user
await expect(client.request(mutation, variables)).resolves.toEqual(
beforeEach(() => {
user = owner
})
it('updates social media with the given id', async () => {
const expected = {
data: {
UpdateSocialMedia: { ...variables },
},
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
describe('given a non-existent id', () => {
// TODO: make sure it throws an error
it('does not update if the the given id does not exist', async () => {
variables.id = 'some-id'
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
})
describe('delete social media', () => {
let mutation, variables
beforeEach(async () => {
headers = await login(ownerParams)
client = new GraphQLClient(host, { headers })
const socialMedia = await setUpSocialMedia()
const { CreateSocialMedia } = await client.request(createSocialMediaMutation, { url })
const { id } = CreateSocialMedia
variables = { id }
mutation = deleteSocialMediaMutation
mutation = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
const user = null
const result = await socialMediaAction(user, mutation, variables)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as other user', () => {
// TODO: make sure it throws an authorization error
it('throws authorization error', async () => {
const user = someUser
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated as owner', () => {
let user
beforeEach(async () => {
headers = await login(ownerParams)
client = new GraphQLClient(host, { headers })
user = owner
})
it('deletes social media', async () => {
it('deletes social media with the given id', async () => {
const expected = {
DeleteSocialMedia: {
id: variables.id,
url: url,
data: {
DeleteSocialMedia: {
id: variables.id,
url,
},
},
}
await expect(client.request(mutation, variables)).resolves.toEqual(expected)
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
})
})

View File

@ -2,57 +2,10 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server'
import { undefinedToNull } from '../helpers'
import Resolver from './helpers/Resolver'
const instance = neode()
const _has = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const count = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `
MATCH(u:User {id: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
export const hasMany = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
export const hasOne = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
export default {
Query: {
User: async (object, args, context, resolveInfo) => {
@ -110,42 +63,44 @@ export default {
let [{ email }] = result.records.map(r => r.get('e').properties)
return email
},
...undefinedToNull([
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
]),
...count({
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
}),
...hasOne({
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
}),
...hasMany({
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED]->(related:SocialMedia)',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
...Resolver('User', {
undefinedToNull: [
'actorId',
'avatar',
'coverImg',
'deleted',
'disabled',
'locationName',
'about',
],
count: {
contributionsCount: '-[:WROTE]->(related:Post)',
friendsCount: '<-[:FRIENDS]->(related:User)',
followingCount: '-[:FOLLOWS]->(related:User)',
followedByCount: '<-[:FOLLOWS]-(related:User)',
commentsCount: '-[:WROTE]->(r:Comment)',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
shoutedCount: '-[:SHOUTED]->(related:Post)',
badgesCount: '<-[:REWARDED]-(related:Badge)',
},
hasOne: {
invitedBy: '<-[:INVITED]-(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)',
},
hasMany: {
followedBy: '<-[:FOLLOWS]-(related:User)',
following: '-[:FOLLOWS]->(related:User)',
friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
},
}),
},
}

View File

@ -131,8 +131,3 @@ type SharedInboxEndpoint {
uri: String
}
type SocialMedia {
id: ID!
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -0,0 +1,11 @@
type SocialMedia {
id: ID!
url: String
ownedBy: User! @relation(name: "OWNED_BY", direction: "IN")
}
type Mutation {
CreateSocialMedia(id: ID, url: String!): SocialMedia
UpdateSocialMedia(id: ID!, url: String!): SocialMedia
DeleteSocialMedia(id: ID!): SocialMedia
}

View File

@ -17,7 +17,7 @@ type User {
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT")
socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
#createdAt: DateTime
#updatedAt: DateTime