Files
omnivore/packages/api/src/services/groups.ts

288 lines
8.3 KiB
TypeScript

import { nanoid } from 'nanoid'
import { Group } from '../entity/groups/group'
import { GroupMembership } from '../entity/groups/group_membership'
import { Invite } from '../entity/groups/invite'
import { RuleActionType } from '../entity/rule'
import { User } from '../entity/user'
import { getRepository } from '../entity/utils'
import { homePageURL } from '../env'
import { RecommendationGroup, User as GraphqlUser } from '../generated/graphql'
import { AppDataSource } from '../server'
import { userDataToUser } from '../utils/helpers'
import { createLabel, getLabelByName } from './labels'
import { createRule } from './rules'
export const createGroup = async (input: {
admin: User
name: string
maxMembers?: number | null
expiresInDays?: number | null
description?: string | null
topics?: string[] | null
onlyAdminCanPost?: boolean | null
onlyAdminCanSeeMembers?: boolean | null
}): Promise<[Group, Invite]> => {
const [group, invite] = await AppDataSource.transaction<[Group, Invite]>(
async (t) => {
// Max number of groups a user can create
const maxGroups = 3
const groupCount = await getRepository(Group).countBy({
createdBy: { id: input.admin.id },
})
if (groupCount >= maxGroups) {
throw new Error('Max groups reached')
}
const group = await t.getRepository(Group).save({
name: input.name,
createdBy: input.admin,
description: input.description,
topics: input.topics?.join(','),
onlyAdminCanPost: input.onlyAdminCanPost ?? false,
onlyAdminCanSeeMembers: input.onlyAdminCanSeeMembers ?? false,
})
const code = nanoid(8)
const expirationTime = (() => {
const r = new Date()
r.setDate(r.getDate() + (input.expiresInDays || 7))
return r
})()
const invite = await t.getRepository(Invite).save({
group,
code,
createdBy: input.admin,
maxMembers: input.maxMembers || 12,
expirationTime: expirationTime,
})
// Add the admin to the group as its first user
await t.getRepository(GroupMembership).save({
user: input.admin,
group,
invite,
isAdmin: true,
})
return [group, invite]
}
)
return [group, invite]
}
export const getRecommendationGroups = async (
user: User
): Promise<RecommendationGroup[]> => {
const groupMembers = await getRepository(GroupMembership).find({
where: { user: { id: user.id } },
relations: ['invite', 'group.members.user.profile'],
})
return groupMembers.map((gm) => {
const admins: GraphqlUser[] = []
const members: GraphqlUser[] = []
// Return all members
gm.group.members.forEach((m) => {
const user = userDataToUser(m.user)
if (m.isAdmin) {
admins.push(user)
}
members.push(user)
})
const canSeeMembers = gm.group.onlyAdminCanSeeMembers ? gm.isAdmin : true
return {
id: gm.group.id,
name: gm.group.name,
createdAt: gm.group.createdAt,
updatedAt: gm.group.updatedAt,
inviteUrl: getInviteUrl(gm.invite),
admins,
members: canSeeMembers ? members : [],
topics: gm.group.topics?.split(','),
description: gm.group.description,
canPost: gm.group.onlyAdminCanPost ? gm.isAdmin : true,
canSeeMembers,
}
})
}
export const getInviteUrl = (invite: Invite) => {
return `${homePageURL()}/invite/${invite.code}`
}
export const joinGroup = async (
user: User,
inviteCode: string
): Promise<RecommendationGroup> => {
const invite = await AppDataSource.transaction<Invite>(async (t) => {
// Check if the invite exists
const invite = await t
.getRepository(Invite)
.createQueryBuilder('invite')
.setLock('pessimistic_write')
.innerJoinAndSelect('invite.group', 'group')
.where('invite.code = :inviteCode AND invite.expiration_time >= NOW()', {
inviteCode,
})
.getOne()
if (!invite) {
throw new Error('Invite not found')
}
// Check if exceeded max members considering concurrent requests
await t.query(
`
insert into omnivore.group_membership (user_id, group_id, invite_id)
select $1, $2, $3
from omnivore.group_membership
where group_id = $2
having count(*) < $4`,
[user.id, invite.group.id, invite.id, invite.maxMembers]
)
return invite
})
const group = await getRepository(Group).findOneOrFail({
where: { id: invite.group.id },
relations: ['members', 'members.user.profile'],
})
const admins: GraphqlUser[] = []
const members: GraphqlUser[] = []
// Return all members
group.members.forEach((m) => {
const user = userDataToUser(m.user)
if (m.isAdmin) {
admins.push(user)
}
members.push(user)
})
return {
...group,
inviteUrl: getInviteUrl(invite),
admins,
members: group.onlyAdminCanSeeMembers ? [] : members,
topics: group.topics?.split(','),
description: group.description,
canPost: !group.onlyAdminCanPost,
canSeeMembers: !group.onlyAdminCanSeeMembers,
}
}
export const leaveGroup = async (
user: User,
groupId: string
): Promise<boolean> => {
return AppDataSource.transaction(async (t) => {
const group = await t
.getRepository(Group)
.createQueryBuilder('group')
.setLock('pessimistic_write')
.innerJoinAndSelect('group.members', 'members')
.where('group.id = :groupId', { groupId })
.getOne()
if (!group) {
throw new Error('Group not found')
}
const membership = await t.getRepository(GroupMembership).findOne({
where: { user: { id: user.id }, group: { id: group.id } },
})
if (!membership) {
throw new Error('User not in group')
}
await t.getRepository(GroupMembership).delete(membership.id)
if (group.members.length === 1) {
// delete the group if there are no more members
await t.getRepository(Group).delete(group.id)
return true
}
if (membership.isAdmin) {
// If the user is the only admin, we need to promote another user to admin
const hasAdmin = group.members.some(
(m) => m.isAdmin && m.id !== membership.id
)
if (!hasAdmin) {
const newAdmin = group.members.find((m) => !m.isAdmin)
if (!newAdmin) {
throw new Error('No user found')
}
newAdmin.isAdmin = true
await t.getRepository(GroupMembership).save(newAdmin)
}
}
return true
})
}
export const createLabelAndRuleForGroup = async (
userId: string,
groupName: string
) => {
let label = await getLabelByName(userId, groupName)
if (!label) {
// create a new label for the group
label = await createLabel(userId, { name: groupName })
}
// create a rule to add the label to all pages in the group
const addLabelPromise = createRule(userId, {
name: `Add label ${groupName} to all pages in group ${groupName}`,
actions: [
{
type: RuleActionType.AddLabel,
params: [label.id],
},
],
// always add the label to pages in the group
filter: `recommendedBy:"${groupName}"`,
})
// create a rule to send a notification when a page is recommended
const sendNotificationPromise = createRule(userId, {
name: `Send notification when a page is recommended to ${groupName}`,
actions: [
{
type: RuleActionType.SendNotification,
params: [
`
{
"title": "New page recommended in ${groupName}",
"body": "A new page was added to the group ${groupName}"
}
`,
],
},
],
// add a condition to check if the page is created
filter: `event:created recommendedBy:"${groupName}"`,
})
await Promise.all([addLabelPromise, sendNotificationPromise])
}
// find groups where id is in the groupIds and the user is a member of the group and the user is allowed to post
export const getGroupsWhereUserCanPost = async (
userId: string,
groupIds: string[]
): Promise<Group[]> => {
return getRepository(Group)
.createQueryBuilder('group')
.innerJoin('group.members', 'members1')
.whereInIds(groupIds)
.andWhere('members1.user_id = :userId', { userId })
.andWhere('(members1.is_admin = true OR group.only_admin_can_post = false)')
.innerJoinAndSelect('group.members', 'members')
.innerJoinAndSelect('members.user', 'user')
.getMany()
}