mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-03-01 12:44:37 +00:00
532 lines
20 KiB
TypeScript
532 lines
20 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
import { v4 as uuid } from 'uuid'
|
|
|
|
import { CATEGORIES_MIN, CATEGORIES_MAX } from '@constants/categories'
|
|
import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '@constants/groups'
|
|
import { UserInputError } from '@graphql/errors'
|
|
import { removeHtmlTags } from '@middleware/helpers/cleanHtml'
|
|
|
|
import Resolver from './helpers/Resolver'
|
|
import { images } from './images/images'
|
|
import { createOrUpdateLocations } from './users/location'
|
|
|
|
import type { Context } from '@src/context'
|
|
|
|
const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId) => {
|
|
return session.writeTransaction(async (transaction) => {
|
|
const removeUserFromGroupCypher = `
|
|
MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId})
|
|
DELETE membership
|
|
WITH user, group
|
|
OPTIONAL MATCH (author:User)-[:WROTE]->(p:Post)-[:IN]->(group)
|
|
WHERE NOT group.groupType = 'public'
|
|
AND NOT author.id = $userId
|
|
WITH user, collect(p) AS posts
|
|
FOREACH (post IN posts |
|
|
MERGE (user)-[:CANNOT_SEE]->(post))
|
|
RETURN user {.*}, NULL as membership
|
|
`
|
|
|
|
const transactionResponse = await transaction.run(removeUserFromGroupCypher, {
|
|
groupId,
|
|
userId,
|
|
})
|
|
const [result] = transactionResponse.records.map((record) => {
|
|
return { user: record.get('user'), membership: record.get('membership') }
|
|
})
|
|
if (!result) {
|
|
throw new UserInputError('User is not a member of this group')
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
export default {
|
|
Query: {
|
|
Group: async (_object, params, context: Context, _resolveInfo) => {
|
|
const { isMember, id, slug, first, offset } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await session.readTransaction(async (txc) => {
|
|
if (!context.user) {
|
|
throw new Error('Missing authenticated user.')
|
|
}
|
|
const matchFilters: string[] = []
|
|
if (id !== undefined) matchFilters.push('group.id = $id')
|
|
if (slug !== undefined) matchFilters.push('group.slug = $slug')
|
|
const matchWhere = matchFilters.length ? `WHERE ${matchFilters.join(' AND ')}` : ''
|
|
|
|
const transactionResponse = await txc.run(
|
|
`
|
|
MATCH (group:Group)
|
|
${matchWhere}
|
|
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
|
|
WITH group, membership
|
|
${(isMember === true && "WHERE membership IS NOT NULL AND (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''}
|
|
${(isMember === false && "WHERE membership IS NULL AND (group.groupType IN ['public', 'closed'])") || ''}
|
|
${(isMember === undefined && "WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''}
|
|
RETURN group {.*, myRole: membership.role}
|
|
ORDER BY group.createdAt DESC
|
|
${first !== undefined && offset !== undefined ? 'SKIP toInteger($offset) LIMIT toInteger($first)' : ''}
|
|
`,
|
|
{
|
|
userId: context.user.id,
|
|
id,
|
|
slug,
|
|
first,
|
|
offset,
|
|
},
|
|
)
|
|
return transactionResponse.records.map((record) => record.get('group'))
|
|
})
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
GroupMembers: async (_object, params, context: Context, _resolveInfo) => {
|
|
const { id: groupId, first = 25, offset = 0 } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await session.readTransaction(async (txc) => {
|
|
const groupMemberCypher = `
|
|
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
|
|
RETURN user {.*}, membership {.*}
|
|
SKIP toInteger($offset) LIMIT toInteger($first)
|
|
`
|
|
const transactionResponse = await txc.run(groupMemberCypher, {
|
|
groupId,
|
|
first,
|
|
offset,
|
|
})
|
|
return transactionResponse.records.map((record) => {
|
|
return { user: record.get('user'), membership: record.get('membership') }
|
|
})
|
|
})
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
GroupCount: async (_object, params, context, _resolveInfo) => {
|
|
const { isMember } = params
|
|
const {
|
|
user: { id: userId },
|
|
} = context
|
|
const session = context.driver.session()
|
|
try {
|
|
const result = await session.readTransaction(async (txc) => {
|
|
let cypher
|
|
if (isMember) {
|
|
cypher = `MATCH (user:User)-[membership:MEMBER_OF]->(group:Group)
|
|
WHERE user.id = $userId
|
|
AND membership.role IN ['usual', 'admin', 'owner', 'pending']
|
|
RETURN toString(count(group)) AS count`
|
|
} else {
|
|
cypher = `MATCH (group:Group)
|
|
OPTIONAL MATCH (user:User)-[membership:MEMBER_OF]->(group)
|
|
WHERE user.id = $userId
|
|
WITH group, membership
|
|
WHERE group.groupType IN ['public', 'closed']
|
|
OR membership.role IN ['usual', 'admin', 'owner']
|
|
RETURN toString(count(group)) AS count`
|
|
}
|
|
const transactionResponse = await txc.run(cypher, { userId })
|
|
return transactionResponse.records.map((record) => record.get('count'))[0]
|
|
})
|
|
return parseInt(result, 10) || 0
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
},
|
|
Mutation: {
|
|
CreateGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { config } = context
|
|
const { categoryIds } = params
|
|
delete params.categoryIds
|
|
params.locationName = params.locationName === '' ? null : params.locationName
|
|
if (config.CATEGORIES_ACTIVE && (!categoryIds || categoryIds.length < CATEGORIES_MIN)) {
|
|
throw new UserInputError('Too few categories!')
|
|
}
|
|
if (config.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > CATEGORIES_MAX) {
|
|
throw new UserInputError('Too many categories!')
|
|
}
|
|
if (
|
|
params.description === undefined ||
|
|
params.description === null ||
|
|
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
|
|
) {
|
|
throw new UserInputError('Description too short!')
|
|
}
|
|
params.id = params.id || uuid()
|
|
const session = context.driver.session()
|
|
try {
|
|
const group = await session.writeTransaction(async (transaction) => {
|
|
if (!context.user) {
|
|
throw new Error('Missing authenticated user.')
|
|
}
|
|
const categoriesCypher =
|
|
config.CATEGORIES_ACTIVE && categoryIds
|
|
? `
|
|
WITH group, membership
|
|
UNWIND $categoryIds AS categoryId
|
|
MATCH (category:Category {id: categoryId})
|
|
MERGE (group)-[:CATEGORIZED]->(category)
|
|
`
|
|
: ''
|
|
const ownerCreateGroupTransactionResponse = await transaction.run(
|
|
`
|
|
CREATE (group:Group)
|
|
SET group += $params
|
|
SET group.createdAt = toString(datetime())
|
|
SET group.updatedAt = toString(datetime())
|
|
WITH group
|
|
MATCH (owner:User {id: $userId})
|
|
MERGE (owner)-[:CREATED]->(group)
|
|
MERGE (owner)-[membership:MEMBER_OF]->(group)
|
|
SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role = 'owner'
|
|
${categoriesCypher}
|
|
RETURN group {.*, myRole: membership.role}
|
|
`,
|
|
{ userId: context.user.id, categoryIds, params },
|
|
)
|
|
const [group] = ownerCreateGroupTransactionResponse.records.map((record) =>
|
|
record.get('group'),
|
|
)
|
|
return group
|
|
})
|
|
// TODO: put in a middleware, see "UpdateGroup", "UpdateUser"
|
|
await createOrUpdateLocations('Group', params.id, params.locationName, session, context)
|
|
return group
|
|
} catch (error) {
|
|
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
|
throw new UserInputError('Group with this slug already exists!')
|
|
throw error
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
UpdateGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { config } = context
|
|
const { categoryIds } = params
|
|
delete params.categoryIds
|
|
const { id: groupId, avatar: avatarInput } = params
|
|
delete params.avatar
|
|
params.locationName = params.locationName === '' ? null : params.locationName
|
|
|
|
if (config.CATEGORIES_ACTIVE && categoryIds) {
|
|
if (categoryIds.length < CATEGORIES_MIN) {
|
|
throw new UserInputError('Too few categories!')
|
|
}
|
|
if (categoryIds.length > CATEGORIES_MAX) {
|
|
throw new UserInputError('Too many categories!')
|
|
}
|
|
}
|
|
if (
|
|
params.description &&
|
|
removeHtmlTags(params.description).length < DESCRIPTION_WITHOUT_HTML_LENGTH_MIN
|
|
) {
|
|
throw new UserInputError('Description too short!')
|
|
}
|
|
const session = context.driver.session()
|
|
try {
|
|
const group = await session.writeTransaction(async (transaction) => {
|
|
if (!context.user) {
|
|
throw new Error('Missing authenticated user.')
|
|
}
|
|
if (config.CATEGORIES_ACTIVE && categoryIds?.length) {
|
|
await transaction.run(
|
|
`
|
|
MATCH (group:Group {id: $groupId})-[previousRelations:CATEGORIZED]->(:Category)
|
|
DELETE previousRelations
|
|
`,
|
|
{ groupId },
|
|
)
|
|
}
|
|
let updateGroupCypher = `
|
|
MATCH (group:Group {id: $groupId})
|
|
SET group += $params
|
|
SET group.updatedAt = toString(datetime())
|
|
WITH group
|
|
`
|
|
if (config.CATEGORIES_ACTIVE && categoryIds?.length) {
|
|
updateGroupCypher += `
|
|
UNWIND $categoryIds AS categoryId
|
|
MATCH (category:Category {id: categoryId})
|
|
MERGE (group)-[:CATEGORIZED]->(category)
|
|
WITH group
|
|
`
|
|
}
|
|
updateGroupCypher += `
|
|
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
|
|
RETURN group {.*, myRole: membership.role}
|
|
`
|
|
const transactionResponse = await transaction.run(updateGroupCypher, {
|
|
groupId,
|
|
userId: context.user.id,
|
|
categoryIds,
|
|
params,
|
|
})
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
if (avatarInput) {
|
|
await images(context.config).mergeImage(group, 'AVATAR_IMAGE', avatarInput, {
|
|
transaction,
|
|
})
|
|
}
|
|
return group
|
|
})
|
|
// TODO: put in a middleware, see "CreateGroup", "UpdateUser"
|
|
await createOrUpdateLocations('Group', params.id, params.locationName, session, context)
|
|
return group
|
|
} catch (error) {
|
|
if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')
|
|
throw new UserInputError('Group with this slug already exists!')
|
|
throw error
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
JoinGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
const result = await session.writeTransaction(async (transaction) => {
|
|
const joinGroupCypher = `
|
|
MATCH (user:User {id: $userId}), (group:Group {id: $groupId})
|
|
MERGE (user)-[membership:MEMBER_OF]->(group)
|
|
ON CREATE SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role =
|
|
CASE WHEN group.groupType = 'public'
|
|
THEN 'usual'
|
|
ELSE 'pending'
|
|
END
|
|
RETURN user {.*}, membership {.*}
|
|
`
|
|
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
|
|
return transactionResponse.records.map((record) => {
|
|
return { user: record.get('user'), membership: record.get('membership') }
|
|
})
|
|
})
|
|
if (!result[0]) {
|
|
throw new UserInputError('Could not find User or Group')
|
|
}
|
|
return result[0]
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
LeaveGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
ChangeGroupMemberRole: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId, roleInGroup } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await session.writeTransaction(async (transaction) => {
|
|
let postRestrictionCypher = ''
|
|
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
|
|
postRestrictionCypher = `
|
|
WITH group, member, membership
|
|
FOREACH (restriction IN [(member)-[r:CANNOT_SEE]->(:Post)-[:IN]->(group) | r] |
|
|
DELETE restriction)`
|
|
} else {
|
|
postRestrictionCypher = `
|
|
With group, member, membership
|
|
FOREACH (post IN [(p:Post)-[:IN]->(group) | p] |
|
|
MERGE (member)-[:CANNOT_SEE]->(post))`
|
|
}
|
|
|
|
const joinGroupCypher = `
|
|
MATCH (member:User {id: $userId})
|
|
MATCH (group:Group {id: $groupId})
|
|
MERGE (member)-[membership:MEMBER_OF]->(group)
|
|
ON CREATE SET
|
|
membership.createdAt = toString(datetime()),
|
|
membership.updatedAt = null,
|
|
membership.role = $roleInGroup
|
|
ON MATCH SET
|
|
membership.updatedAt = toString(datetime()),
|
|
membership.role = $roleInGroup
|
|
${postRestrictionCypher}
|
|
RETURN member {.*} as user, membership {.*}
|
|
`
|
|
|
|
const transactionResponse = await transaction.run(joinGroupCypher, {
|
|
groupId,
|
|
userId,
|
|
roleInGroup,
|
|
})
|
|
const [member] = transactionResponse.records.map((record) => {
|
|
return { user: record.get('user'), membership: record.get('membership') }
|
|
})
|
|
return member
|
|
})
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
RemoveUserFromGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
const { groupId, userId } = params
|
|
const session = context.driver.session()
|
|
try {
|
|
return await removeUserFromGroupWriteTxResultPromise(session, groupId, userId)
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
muteGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
throw new Error('Missing authenticated user.')
|
|
}
|
|
const { groupId } = params
|
|
const userId = context.user.id
|
|
const session = context.driver.session()
|
|
try {
|
|
return await session.writeTransaction(async (transaction) => {
|
|
const transactionResponse = await transaction.run(
|
|
`
|
|
MATCH (group:Group { id: $groupId })
|
|
MATCH (user:User { id: $userId })
|
|
MERGE (user)-[m:MUTED]->(group)
|
|
SET m.createdAt = toString(datetime())
|
|
RETURN group { .* }
|
|
`,
|
|
{
|
|
groupId,
|
|
userId,
|
|
},
|
|
)
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
return group
|
|
})
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
unmuteGroup: async (_parent, params, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
throw new Error('Missing authenticated user.')
|
|
}
|
|
const { groupId } = params
|
|
const userId = context.user.id
|
|
const session = context.driver.session()
|
|
try {
|
|
return await session.writeTransaction(async (transaction) => {
|
|
const transactionResponse = await transaction.run(
|
|
`
|
|
MATCH (group:Group { id: $groupId })
|
|
MATCH (user:User { id: $userId })
|
|
OPTIONAL MATCH (user)-[m:MUTED]->(group)
|
|
DELETE m
|
|
RETURN group { .* }
|
|
`,
|
|
{
|
|
groupId,
|
|
userId,
|
|
},
|
|
)
|
|
const [group] = transactionResponse.records.map((record) => record.get('group'))
|
|
return group
|
|
})
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
},
|
|
},
|
|
Group: {
|
|
myRole: 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 {id: $user.id})-[membership:MEMBER_OF]->(group:Group {id: $parent.id})
|
|
RETURN membership.role as role
|
|
`,
|
|
variables: {
|
|
user: context.user,
|
|
parent,
|
|
},
|
|
})
|
|
).records.map((r) => r.get('role'))[0]
|
|
},
|
|
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'))
|
|
},
|
|
currentlyPinnedPostsCount: async (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!parent.id) {
|
|
throw new Error('Can not identify selected Group!')
|
|
}
|
|
const result = await context.database.query({
|
|
query: `
|
|
MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: $group.id})
|
|
RETURN toString(count(pinnedPosts)) as count`,
|
|
variables: { group: parent },
|
|
})
|
|
return result.records[0].get('count')
|
|
},
|
|
...Resolver('Group', {
|
|
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
|
|
hasMany: {
|
|
categories: '-[:CATEGORIZED]->(related:Category)',
|
|
posts: '<-[:IN]-(related:Post)',
|
|
},
|
|
hasOne: {
|
|
avatar: '-[:AVATAR_IMAGE]->(related:Image)',
|
|
location: '-[:IS_IN]->(related:Location)',
|
|
},
|
|
boolean: {
|
|
isMutedByMe:
|
|
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
|
|
},
|
|
count: {
|
|
membersCount: '<-[:MEMBER_OF]-(related:User)',
|
|
},
|
|
}),
|
|
name: (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
return parent.groupType === 'hidden' ? '' : parent.name
|
|
}
|
|
return parent.name
|
|
},
|
|
about: (parent, _args, context: Context, _resolveInfo) => {
|
|
if (!context.user) {
|
|
return parent.groupType === 'hidden' ? '' : parent.about
|
|
}
|
|
return parent.about
|
|
},
|
|
},
|
|
}
|