[ABANDONED] API server for Flexor social network.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

1249 lines
42 KiB

// groups.ts
// Copyright (C) 2020 Dwayne Harris
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {
FastifyInstance,
Plugin,
DefaultQuery,
DefaultParams,
DefaultBody,
RouteShorthandOptions,
DefaultHeaders,
} from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'
import { MIN_ID_LENGTH, MAX_NAME_LENGTH, GROUP_LISTING_PARTITION_KEY, USER_LISTING_PARTITION_KEY, ADMINS } from '../../constants'
import { errorSchema, groupSchema, userSchema } from '../../schemas'
import { unauthorizedError, badRequestError, notFoundError, serverError, forbiddenError } from '../../lib/errors'
import { getUsers, getUserMembership, getUser, updateItem } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import { createInvitationCode } from '../../lib/utils'
import { attachMedia, deleteMedia } from '../../lib/media'
import {
User,
Group,
GroupListing,
GroupMembership,
UserBlock,
GroupBlock,
GroupRegistrationType,
GroupStatus,
GroupMembershipType,
GroupItemType,
BlockType,
UserItemType,
GroupLog,
GroupInvitation,
UserListing,
} from '../../types/collections'
import { PluginOptions } from '../../types'
function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Check Group ID availability.',
tags: ['group'],
body: {
type: 'object',
required: ['name'],
properties: {
name: {
type: 'string',
maxLength: MAX_NAME_LENGTH,
},
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
id: { type: 'string' },
available: { type: 'boolean' },
},
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/group/available', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const id = normalize(request.body.name)
const group = await getItem<Group>({
container: containerFor(server.database.client, 'Groups'),
id,
})
return {
id,
available: !group,
}
})
}
function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name: string
about?: string
registration: GroupRegistrationType
imageUrl?: string
coverImageUrl?: string
iconImageUrl?: string
theme: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Create a new Group.',
tags: ['group'],
body: {
type: 'object',
required: ['name', 'registration'],
properties: {
name: {
type: 'string',
minLength: MIN_ID_LENGTH,
maxLength: MAX_NAME_LENGTH,
},
about: { type: 'string' },
registration: {
type: 'string',
enum: ['open', 'approval', 'closed'],
},
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
iconImageUrl: { type: 'string' },
theme: { type: 'string' },
},
},
response: {
201: {
description: 'Group created.',
type: 'object',
properties: {
id: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/group', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
const { resource: viewer } = await viewerItem.read<User>()
if (viewer.groupId) return forbiddenError(reply)
const { name, about, registration, imageUrl, coverImageUrl, iconImageUrl, theme } = request.body
const id = normalize(name)
const groupContainer = containerFor(server.database.client, 'Groups')
const directoryContainer = containerFor(server.database.client, 'Directory')
const existingGroup = await getItem<Group>({ container: groupContainer, id })
if (existingGroup) return badRequestError(reply, 'Name already used')
const group: Group = {
id: id,
pk: id,
t: GroupItemType.Group,
userId: request.viewer.id,
name,
about,
registration,
members: 0,
imageUrl,
coverImageUrl,
iconImageUrl,
theme,
status: GroupStatus.Pending,
active: true,
created: Date.now(),
}
const membership: GroupMembership = {
id: request.viewer.id,
pk: id,
t: GroupItemType.Membership,
userId: request.viewer.id,
pending: false,
membership: GroupMembershipType.Admin,
created: Date.now(),
}
await groupContainer.items.create(group)
await groupContainer.items.create(membership)
await viewerItem.replace<User>({
...viewer,
groupId: group.id,
})
await updateItem<UserListing>(directoryContainer.item(request.viewer.id, USER_LISTING_PARTITION_KEY), {
groupId: group.id,
})
await groupContainer.items.create<GroupLog>({
pk: group.id,
t: GroupItemType.Log,
userId: request.viewer.id,
content: 'created',
created: Date.now(),
})
const mediaContainer = containerFor(server.database.client, 'Media')
if (imageUrl) await attachMedia(mediaContainer, imageUrl)
if (coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
if (iconImageUrl) await attachMedia(mediaContainer, iconImageUrl)
reply.code(201)
return {
id: group.id,
}
})
}
function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get a Group.',
tags: ['group'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
200: groupSchema,
400: errorSchema,
},
},
}
server.get<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/group/:id', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const container = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({
container,
id: request.params.id,
})
if (!group) return notFoundError(reply)
if (request.viewer) {
const memberships = await queryItems<GroupMembership>({
container,
query: createQuerySpec(
`
SELECT * FROM Groups g WHERE
g.pk = @pk AND
g.t = @type AND
g.userId = @userId AND
g.pending = false
`,
{
pk: group.id,
type: GroupItemType.Membership,
userId: request.viewer.id,
}
),
logger: request.log,
})
if (memberships.length > 0) {
return {
...group,
membership: memberships[0].membership,
}
}
}
return group
})
}
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name?: string
about?: string
registration?: string
imageUrl?: string
coverImageUrl?: string
iconImageUrl?: string
theme?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Update a Group.',
tags: ['group'],
body: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: MIN_ID_LENGTH,
maxLength: MAX_NAME_LENGTH,
},
about: { type: 'string' },
registration: {
type: 'string',
enum: ['open', 'approval', 'closed'],
},
imageUrl: { type: 'string' },
coverImageUrl: { type: 'string' },
iconImageUrl: { type: 'string' },
theme: { type: 'string' },
},
},
response: {
204: {
description: 'Group updated.',
type: 'object',
},
400: errorSchema,
},
},
}
server.put<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/group/:id', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const membership = await getUserMembership(server.database.client, request.viewer.id, request.log)
if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const directoryContainer = containerFor(server.database.client, 'Directory')
const groupItem = groupContainer.item(request.params.id, request.params.id)
const listingItem = directoryContainer.item(request.params.id, GROUP_LISTING_PARTITION_KEY)
const { resource: group } = await groupItem.read<Group>()
if (!group) return notFoundError(reply)
const { resource: listing } = await listingItem.read<GroupListing>()
const {
name,
about,
registration,
imageUrl,
coverImageUrl,
iconImageUrl,
theme,
} = request.body
const mediaContainer = containerFor(server.database.client, 'Media')
if (group.imageUrl && !imageUrl) await deleteMedia(group.imageUrl)
if (group.coverImageUrl && !coverImageUrl) await deleteMedia(group.coverImageUrl)
if (group.iconImageUrl && !iconImageUrl) await deleteMedia(group.iconImageUrl)
if (!group.imageUrl && imageUrl) await attachMedia(mediaContainer, imageUrl)
if (!group.coverImageUrl && coverImageUrl) await attachMedia(mediaContainer, coverImageUrl)
if (!group.iconImageUrl && iconImageUrl) await attachMedia(mediaContainer, iconImageUrl)
if (name) group.name = name
if (about) group.about = about
if (registration) group.registration = registration as GroupRegistrationType
if (imageUrl) group.imageUrl = imageUrl
if (coverImageUrl) group.coverImageUrl = coverImageUrl
if (iconImageUrl) group.iconImageUrl = iconImageUrl
if (theme) group.theme = theme
await groupItem.replace<Group>(group)
if (listing) {
if (registration) listing.registration = registration as GroupRegistrationType
await listingItem.replace<GroupListing>(listing)
}
await groupContainer.items.create<GroupLog>({
pk: group.id,
t: GroupItemType.Log,
userId: request.viewer.id,
content: 'updated',
created: Date.now(),
})
reply.code(204)
})
}
function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
interface Body {
description?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Block a Group.',
tags: ['group'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
description: { type: 'string' },
},
},
response: {
204: {
description: 'Group blocked.',
type: 'object',
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, Body>('/v1/group/:id/block', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container: groupContainer, id: request.params.id })
if (!group) return notFoundError(reply)
await containerFor(server.database.client, 'Users').items.create<UserBlock>({
blockedId: group.id,
pk: request.viewer.id,
t: UserItemType.Block,
blockType: BlockType.Group,
description: request.body.description,
created: Date.now(),
})
await groupContainer.items.create<GroupBlock>({
pk: group.id,
t: GroupItemType.Block,
blockedId: group.id,
userId: request.viewer.id,
created: Date.now(),
})
reply.code(204)
})
}
function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Unblock a Group.',
tags: ['group'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
204: {
description: 'Group unblocked.',
type: 'object',
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/group/:id/unblock', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container: groupContainer, id: request.params.id })
if (!group) return notFoundError(reply)
const userBlockQuery = createQuerySpec(`SELECT u.id FROM Users u WHERE u.pk = @pk AND u.blockedId = @blocked AND u.t = @type`, {
pk: request.viewer.id,
blocked: group.id,
type: UserItemType.Block,
})
const userBlocks = await queryItems<UserBlock>({
container: userContainer,
query: userBlockQuery,
logger: request.log
})
for (const userBlock of userBlocks) {
await userContainer.item(userBlock.id!, request.viewer.id).delete()
}
const groupBlockQuery = createQuerySpec(
`SELECT g.id FROM Groups g WHERE g.pk = @pk AND u.blockedId = @blocked AND u.userId = @viewer AND u.t = @type`,
{
pk: group.id,
blocked: group.id,
viewer: request.viewer.id,
type: GroupItemType.Block,
}
)
const groupBlocks = await queryItems<UserBlock>({
container: groupContainer,
query: groupBlockQuery,
logger: request.log
})
for (const groupBlock of groupBlocks) {
await groupContainer.item(groupBlock.id!, group.id).delete()
}
reply.code(204)
})
}
function pendingRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get Groups awaiting activation.',
tags: ['group', 'admin'],
querystring: {
type: 'object',
properties: {
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
groups: {
type: 'array',
items: groupSchema,
},
continuation: { type: 'string' },
}
},
400: errorSchema,
},
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/groups/pending', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer || !ADMINS.includes(viewer.email)) return forbiddenError(reply)
const { continuation } = request.query
const container = containerFor(server.database.client, 'Groups')
const { resources: groups, requestCharge, continuation: newContinuation } = await container.items.query<Group>(
createQuerySpec('SELECT * FROM Groups g WHERE g.t = @type AND g.status = @status', {
type: GroupItemType.Group,
status: GroupStatus.Pending,
}),
{
maxItemCount: 20,
continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
return {
groups,
continuation: newContinuation,
}
})
}
function activateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Activate a Group.',
tags: ['group', 'admin'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
204: {
description: 'Group activated.',
type: 'object',
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/group/:id/activate', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer || !ADMINS.includes(viewer.email)) return forbiddenError(reply)
const container = containerFor(server.database.client, 'Groups')
const groupItem = container.item(request.params.id, request.params.id)
const { resource: group } = await groupItem.read<Group>()
if (!group) return notFoundError(reply)
if (group.active && group.status === 'paid') {
return badRequestError(reply, 'Already activated')
}
await groupItem.replace<Group>({
...group,
active: true,
status: GroupStatus.Paid,
})
const directoryContainer = containerFor(server.database.client, 'Directory')
const listingItem = directoryContainer.item(request.params.id, GROUP_LISTING_PARTITION_KEY)
const { resource: listing } = await listingItem.read<GroupListing>()
if (!listing) {
await directoryContainer.items.create<GroupListing>({
id: group.id,
pk: GROUP_LISTING_PARTITION_KEY,
registration: group.registration,
members: 1,
created: Date.now(),
})
}
reply.code(204)
})
}
function listRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
sort?: string
registration?: GroupRegistrationType
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get a list of Groups.',
tags: ['group'],
querystring: {
type: 'object',
properties: {
sort: {
type: 'string',
enum: ['name', 'members', 'points'],
},
registration: {
type: 'string',
enum: ['open', 'approval', 'closed'],
},
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
groups: {
type: 'array',
items: groupSchema,
},
continuation: { type: 'string' },
}
},
400: errorSchema,
}
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/groups', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const { sort = 'members', registration, continuation } = request.query
let registrationString = ''
if (registration) registrationString = `AND d.registration = '${registration}'`
const directoryContainer = containerFor(server.database.client, 'Directory')
const { resources: groups, requestCharge, continuation: newContinuation } = await directoryContainer.items.query<GroupListing>(
`SELECT d.id FROM Directory d WHERE d.pk = '${GROUP_LISTING_PARTITION_KEY}' ${registrationString} ORDER BY d.${sort}`,
{
maxItemCount: 40,
continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
return {
groups: await queryItems<Group>({
container: containerFor(server.database.client, 'Groups'),
query: createQuerySpec(
'SELECT * FROM Groups g WHERE ARRAY_CONTAINS(@ids, g.id)',
{ ids: groups.map(g => g.id) }
),
logger: request.log,
}),
continuation: newContinuation,
}
})
}
function membersRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
type?: string
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get Group members.',
tags: ['group'],
querystring: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['admin', 'moderator', 'member'],
},
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
members: {
type: 'array',
items: userSchema,
},
continuation: { type: 'string' },
}
},
400: errorSchema,
}
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/group/:id/members', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container: groupContainer, id: request.params.id })
if (!group) return notFoundError(reply)
const { type, continuation } = request.query
let typeString = ''
if (type) {
typeString = `AND g.membership = '${type}'`
}
const { resources: memberships, requestCharge, continuation: newContinuation } = await groupContainer.items.query<GroupMembership>(
`SELECT g.userId, g.membership FROM Groups g WHERE g.pk = '${group.id}' AND g.t = '${GroupItemType.Membership}' ${typeString} ORDER BY g.created DESC`,
{
maxItemCount: 100,
continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
const users = await getUsers(server.database.client, memberships.map(membership => membership.userId), request.log)
return {
members: users.map(user => {
const m = memberships.find(membership => membership.userId === user.id)
return {
...user,
membership: m ? m.membership : undefined,
}
}),
continuation: newContinuation,
}
})
}
function createInvitationRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
expiration?: number
limit?: number
}
const options: RouteShorthandOptions = {
schema: {
description: 'Create a Group Invitation.',
tags: ['group'],
body: {
type: 'object',
properties: {
expiration: { type: 'number' },
limit: { type: 'number' },
},
},
response: {
201: {
description: 'Group Invitation created.',
type: 'object',
properties: {
code: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/v1/group/invitation', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const membership = await getUserMembership(server.database.client, request.viewer.id, request.log)
if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply)
const container = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container, id: membership.pk })
if (!group) return notFoundError(reply)
const code = createInvitationCode()
await container.items.create<GroupInvitation>({
id: code,
pk: group.id,
t: GroupItemType.Invitation,
userId: request.viewer.id,
limit: request.body.limit,
expiration: request.body.expiration,
uses: 0,
active: true,
created: Date.now(),
})
await container.items.create<GroupLog>({
pk: group.id,
t: GroupItemType.Log,
userId: request.viewer.id,
content: `created invitation: ${code}`,
created: Date.now(),
})
reply.code(201)
return {
code,
}
})
}
function invitationsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get a list of Group Invitations.',
tags: ['group'],
querystring: {
type: 'object',
properties: {
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
invitations: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
user: userSchema,
uses: { type: 'number' },
expiration: { type: 'number' },
limit: { type: 'number' },
created: { type: 'number' },
},
},
},
continuation: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/group/invitations', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const membership = await getUserMembership(server.database.client, request.viewer.id, request.log)
if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply)
const container = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container, id: membership.pk })
if (!group) return notFoundError(reply)
const { continuation } = request.query
const { resources: invitations, requestCharge, continuation: newContinuation } = await container.items.query<GroupLog>(
`SELECT * FROM Groups g WHERE
g.pk = '${group.id}' AND
g.t = '${GroupItemType.Invitation}' AND
g.expiration < GETCURRENTTIMESTAMP() AND
g.active = true ORDER BY g.created DESC`,
{
maxItemCount: 80,
continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
const users = await getUsers(server.database.client, invitations.map(invitation => invitation.userId), request.log)
return {
invitations: invitations.map(invitation => ({
...invitation,
user: users.find(user => user.id === invitation.userId),
userId: undefined,
})),
continuation: newContinuation,
}
})
}
function logsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get Group logs.',
tags: ['group'],
querystring: {
type: 'object',
properties: {
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
logs: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
user: userSchema,
content: { type: 'string' },
created: { type: 'number' },
},
},
},
continuation: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/group/logs', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const membership = await getUserMembership(server.database.client, request.viewer.id, request.log)
if (!membership || membership.membership !== GroupMembershipType.Admin) return unauthorizedError(reply)
const container = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({ container, id: membership.pk })
if (!group) return notFoundError(reply)
const { continuation } = request.query
const { resources: logs, requestCharge, continuation: newContinuation } = await container.items.query<GroupLog>(
`SELECT * FROM Groups g WHERE g.pk = '${group.id}' AND g.t = '${GroupItemType.Log}' ORDER BY g.created DESC`,
{
maxItemCount: 80,
continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
const users = await getUsers(server.database.client, logs.map(log => log.userId), request.log)
return {
logs: logs.map(log => ({
...log,
user: users.find(user => user.id === log.userId),
userId: undefined,
})),
continuation: newContinuation,
}
})
}
function pendingMembersRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Get pending Group members.',
tags: ['group', 'user'],
querystring: {
type: 'object',
properties: {
continuation: { type: 'string' },
},
},
response: {
200: {
description: 'Successful response.',
type: 'object',
properties: {
users: {
type: 'array',
items: userSchema,
},
continuation: { type: 'string' },
}
},
400: errorSchema,
}
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/v1/group/members/pending', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer) return unauthorizedError(reply)
if (!viewer.group) return badRequestError(reply)
const membership = await getUserMembership(server.database.client, viewer.id, request.log)
if (!membership) return serverError(reply)
if (membership.membership !== GroupMembershipType.Admin) return forbiddenError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const { resources: memberships, requestCharge, continuation: newContinuation } = await groupContainer.items.query<GroupMembership>(
`SELECT g.userId, g.intro FROM Groups g WHERE g.pk = '${viewer.group.id}' AND g.t = '${GroupItemType.Membership}' AND g.pending = true ORDER BY g.created DESC`,
{
maxItemCount: 100,
continuation: request.query.continuation,
}
).fetchAll()
request.log.trace('Query: %d', requestCharge)
const users = await getUsers(server.database.client, memberships.map(membership => membership.userId), request.log)
return {
users: users.map(user => {
const m = memberships.find(membership => membership.userId === user.id)
return {
...user,
intro: m ? m.intro : undefined,
}
}),
continuation: newContinuation,
}
})
}
function approveMemberRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Approve a pending Group member.',
tags: ['group', 'user'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
200: {
description: 'Member approved.',
type: 'object',
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/group/member/:id/approve', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer) return unauthorizedError(reply)
if (!viewer.group) return badRequestError(reply)
const viewerMembership = await getUserMembership(server.database.client, viewer.id, request.log)
if (!viewerMembership) return serverError(reply)
if (viewerMembership.membership !== GroupMembershipType.Admin) return forbiddenError(reply)
const membership = await getUserMembership(server.database.client, request.params.id, request.log)
if (!membership) return notFoundError(reply)
if (!membership.pending) return badRequestError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const groupItem = groupContainer.item(viewer.group.id, viewer.group.id)
const membershipItem = groupContainer.item(membership.id!, membership.pk)
await groupItem.replace<Group>({
...viewer.group,
members: viewer.group.members + 1,
})
await membershipItem.replace<GroupMembership>({
...membership,
pending: false,
})
})
}
function rejectMemberRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Params {
id: string
}
const options: RouteShorthandOptions = {
schema: {
description: 'Reject a pending Group member.',
tags: ['group', 'user'],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
response: {
200: {
description: 'Member rejected.',
type: 'object',
},
400: errorSchema,
},
},
}
server.post<DefaultQuery, Params, DefaultHeaders, DefaultBody>('/v1/group/member/:id/reject', options, async (request, reply) => {
if (!server.database) return serverError(reply)
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getUser(server.database.client, request.viewer.id)
if (!viewer) return unauthorizedError(reply)
if (!viewer.group) return badRequestError(reply)
const viewerMembership = await getUserMembership(server.database.client, viewer.id, request.log)
if (!viewerMembership) return serverError(reply)
if (viewerMembership.membership !== GroupMembershipType.Admin) return forbiddenError(reply)
const membership = await getUserMembership(server.database.client, request.params.id, request.log)
if (!membership) return notFoundError(reply)
if (!membership.pending) return badRequestError(reply)
// TODO
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
availabilityRoute(server)
createRoute(server)
getRoute(server)
updateRoute(server)
blockRoute(server)
unblockRoute(server)
pendingRoute(server)
activateRoute(server)
listRoute(server)
membersRoute(server)
createInvitationRoute(server)
invitationsRoute(server)
logsRoute(server)
pendingMembersRoute(server)
approveMemberRoute(server)
rejectMemberRoute(server)
}
export default plugin