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
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
|