Dwayne Harris 5 years ago
parent
commit
54a4288ab0
  1. 33
      src/lib/collections.ts
  2. 47
      src/plugins/api/authentication.ts
  3. 273
      src/plugins/api/groups.ts
  4. 48
      src/plugins/api/posts.ts
  5. 40
      src/plugins/api/users.ts
  6. 14
      src/types/collections.ts

33
src/lib/collections.ts

@ -3,8 +3,8 @@ import { Logger } from 'fastify'
import uniq from 'lodash/uniq'
import { DatabaseItem } from '../types'
import { containerFor, createQuerySpec, queryItems } from './database'
import { User, UserSubscription, UserBlock, UserItemType } from '../types/collections'
import { containerFor, createQuerySpec, queryItems, getItem } from './database'
import { User, UserSubscription, UserBlock, GroupMembership, UserItemType, GroupItemType } from '../types/collections'
export async function getUsers(client: CosmosClient, ids: string[], logger?: Logger): Promise<User[]> {
return await queryItems<User>({
@ -51,3 +51,32 @@ export async function getUserBlocks(client: CosmosClient, from: string, to: stri
logger,
})
}
export async function getUserMembership(client: CosmosClient, userId: string, logger?: Logger): Promise<GroupMembership | undefined> {
const user = await getItem<User>({
container: containerFor(client, 'Users'),
id: userId,
})
if (!user) return
if (!user.group) return
const memberships = await queryItems<GroupMembership>({
container: containerFor(client, 'Groups'),
query: createQuerySpec(
`SELECT * FROM Groups g WHERE
g.pk = @pk AND
g.t = @type AND
g.userId = @user
`,
{
pk: user.group.id,
type: GroupItemType.Membership,
user: user.id,
}
),
logger,
})
if (memberships.length > 0) return memberships[0]
}

47
src/plugins/api/authentication.ts

@ -80,44 +80,35 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/register', options, async (request, reply) => {
if (!server.database) return serverError(reply)
const { name, email, password, invitation } = request.body
const { name, email, password, invitation: code } = request.body
const id = normalize(request.body.id)
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const existingUser = await getItem<User>({
container: userContainer,
id,
logger: request.log
})
const existingUser = await getItem<User>({ container: userContainer, id })
if (existingUser) return badRequestFormError(reply, 'id', 'User id already taken')
let userPending = false
let invitation: GroupInvitation | undefined
let group: Group | undefined
let groupPartial: GroupPartial | undefined
if (request.body.group) {
group = await getItem<Group>({
container: groupContainer,
id: request.body.group,
logger: request.log
})
group = await getItem<Group>({ container: groupContainer, id: request.body.group })
if (!group) return badRequestFormError(reply, 'group', 'Group not found')
if (invitation) {
if (code) {
const invitationQuery = createQuerySpec(`
SELECT g.id FROM Groups g WHERE
g.id = @invitation
SELECT * FROM Groups g WHERE
g.id = @code
g.pk = @group AND
g.t = @type AND
g.active = true AND
g.expiration < GETCURRENTTIMESTAMP() AND
g.uses < g.limit
`, {
invitation,
code,
group: group.id,
type: GroupItemType.Invitation,
})
@ -129,6 +120,7 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
})
if (invitations.length === 0) return badRequestFormError(reply, 'invitation', 'Invalid invitation code')
invitation = invitations[0]
}
if (group.registration === GroupRegistrationType.Closed && !invitation) return badRequestFormError(reply, 'group', 'Group registration closed')
@ -178,9 +170,18 @@ function registerRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
userId: user.id,
pending: userPending,
membership: GroupMembershipType.Member,
invitation,
invitation: code,
created: Date.now(),
})
if (invitation) {
const invitationItem = groupContainer.item(invitation.id, group.id)
await invitationItem.replace<GroupInvitation>({
...invitation,
uses: invitation.uses + 1,
})
}
}
return {
@ -220,11 +221,7 @@ function authenticateRoute(server: FastifyInstance<Server, IncomingMessage, Serv
const container = containerFor(server.database.client, 'Users')
const id = normalize(request.body.id)
const user = await getItem<User>({
container,
id,
logger: request.log
})
const user = await getItem<User>({ container, id })
if (!user) return badRequestFormError(reply, 'id', 'User not found')
const result = await verifyPassword(user.passwordHash, request.body.password)
@ -300,8 +297,7 @@ function refreshRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
const tokenItem = container.item(request.body.refresh, userId)
if (!tokenItem) return badRequestError(reply, 'Invalid refresh token')
const { resource: token, requestCharge } = await tokenItem.read<UserToken>()
request.log.trace('Get: %d', requestCharge)
const { resource: token } = await tokenItem.read<UserToken>()
if (token.expires < Date.now()) return badRequestError(reply, 'Refresh token expired')
@ -338,7 +334,6 @@ function selfRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
const viewer = await getItem<User>({
container: containerFor(server.database.client, 'Users'),
id: request.viewer.id,
logger: request.log
})
if (!viewer) return unauthorizedError(reply)

273
src/plugins/api/groups.ts

@ -8,14 +8,16 @@ import {
DefaultHeaders,
} from 'fastify'
import merge from 'lodash/merge'
import { JSONObject } from '@azure/cosmos'
import { Server, IncomingMessage, ServerResponse } from 'http'
import merge from 'lodash/merge'
import { MIN_ID_LENGTH, MAX_NAME_LENGTH, GROUP_LISTING_PARTITION_KEY } from '../../constants'
import { errorSchema, groupListingSchema, userSchema } from '../../schemas'
import { unauthorizedError, badRequestError, notFoundError, serverError } from '../../lib/errors'
import { getUsers } from '../../lib/collections'
import { getUsers, getUserMembership } from '../../lib/collections'
import { containerFor, createQuerySpec, queryItems, getItem, normalize } from '../../lib/database'
import { createInvitationCode } from '../../lib/util'
import {
User,
@ -29,7 +31,9 @@ import {
GroupMembershipType,
GroupItemType,
BlockType,
UserItemType
UserItemType,
GroupLog,
GroupInvitation,
} from '../../types/collections'
import { PluginOptions } from '../../types'
@ -72,7 +76,6 @@ function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, Serv
const group = await getItem<Group>({
container: containerFor(server.database.client, 'Groups'),
id,
logger: request.log,
})
return {
@ -124,8 +127,7 @@ function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
if (!request.viewer) return unauthorizedError(reply)
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
const { resource: viewer, requestCharge } = await viewerItem.read<User>()
request.log.trace('Get: %d', requestCharge)
const { resource: viewer } = await viewerItem.read<User>()
const groupContainer = containerFor(server.database.client, 'Groups')
@ -134,11 +136,7 @@ function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
const { name, about, registration } = request.body
const id = normalize(name)
const existingGroup = await getItem<Group>({
container: groupContainer,
id,
logger: request.log
})
const existingGroup = await getItem<Group>({ container: groupContainer, id })
if (existingGroup) return badRequestError(reply, 'Name already used')
const group: Group = {
@ -177,6 +175,14 @@ function createRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
},
})
await groupContainer.items.create<GroupLog>({
pk: group.id,
t: GroupItemType.Log,
userId: request.viewer.id,
content: 'created',
created: Date.now(),
})
return {
id: group.id,
}
@ -211,13 +217,11 @@ function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespons
container: containerFor(server.database.client, 'GroupDirectory'),
id: request.params.id,
partitionKey: 'pk',
logger: request.log,
})
const group = await getItem<Group>({
container: groupContainer,
id: request.params.id,
logger: request.log,
})
const combine = async (group: Group, listing: GroupListing) => {
@ -256,6 +260,82 @@ function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespons
})
}
function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
name?: string
about?: string
registration?: string
}
const options: RouteShorthandOptions = {
schema: {
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'],
},
},
},
response: {
400: errorSchema,
},
},
}
server.put<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/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, 'GroupDirectory')
const groupItem = groupContainer.item(request.params.id, request.params.id)
const groupListingItem = directoryContainer.item(request.params.id, GROUP_LISTING_PARTITION_KEY)
const { resource: group } = await groupItem.read<Group>()
if (!group) return notFoundError(reply)
const { resource: groupListing } = await groupListingItem.read<GroupListing>()
let updates: JSONObject = {}
if (request.body.name) updates.name = request.body.name
if (request.body.about) updates.about = request.body.about
if (request.body.registration) updates.registration = request.body.registration
await groupItem.replace<Group>({
...group,
...updates,
})
if (groupListing) {
await groupListingItem.replace<GroupListing>({
...groupListing,
...updates,
})
}
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
@ -287,11 +367,7 @@ function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespo
if (!request.viewer) return unauthorizedError(reply)
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({
container: groupContainer,
id: request.params.id,
logger: request.log,
})
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>({
@ -338,11 +414,7 @@ function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
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,
logger: request.log
})
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`, {
@ -406,9 +478,7 @@ function activateRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
const container = containerFor(server.database.client, 'Groups')
const groupItem = container.item(request.params.id, request.params.id)
const { resource: group, requestCharge: groupRequestCharge } = await groupItem.read<Group>()
request.log.trace('Get: %d', groupRequestCharge)
const { resource: group } = await groupItem.read<Group>()
if (!group) return notFoundError(reply)
if (group.active && group.status === 'paid') {
@ -423,8 +493,7 @@ function activateRoute(server: FastifyInstance<Server, IncomingMessage, ServerRe
const directoryContainer = containerFor(server.database.client, 'GroupDirectory')
const listingItem = directoryContainer.item(request.params.id, GROUP_LISTING_PARTITION_KEY)
const { resource: listing, requestCharge: listingRequestCharge } = await listingItem.read<GroupListing>()
request.log.trace('Get: %d', listingRequestCharge)
const { resource: listing } = await listingItem.read<GroupListing>()
if (!listing) {
await directoryContainer.items.create<GroupListing>({
@ -550,11 +619,7 @@ function membersRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
const groupContainer = containerFor(server.database.client, 'Groups')
const group = await getItem<Group>({
container: groupContainer,
id: request.params.id,
logger: request.log
})
const group = await getItem<Group>({ container: groupContainer, id: request.params.id })
if (!group) return notFoundError(reply)
const { type, continuation } = request.query
@ -590,15 +655,157 @@ function membersRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
})
}
function createInvitationRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Body {
expiration?: number
limit?: number
}
const options: RouteShorthandOptions = {
schema: {
body: {
type: 'object',
properties: {
expiration: { type: 'number' },
limit: { type: 'number' },
},
},
response: {
200: {
type: 'object',
properties: {
code: { type: 'string' },
},
},
},
},
}
server.post<DefaultQuery, DefaultParams, DefaultHeaders, Body>('/api/group/:id/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: request.params.id })
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(),
})
return {
code,
}
})
}
function logsRoute(server: FastifyInstance<Server, IncomingMessage, ServerResponse>) {
interface Query {
continuation?: string
}
const options: RouteShorthandOptions = {
schema: {
querystring: {
type: 'object',
properties: {
continuation: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
logs: {
type: 'array',
items: {
type: 'object',
properties: {
user: userSchema,
content: { type: 'string' },
created: { type: 'number' },
},
},
},
continuation: { type: 'string' },
},
},
400: errorSchema,
},
},
}
server.get<Query, DefaultParams, DefaultHeaders, DefaultBody>('/api/group/:id/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: request.params.id })
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,
}
})
}
const plugin: Plugin<Server, IncomingMessage, ServerResponse, PluginOptions> = async server => {
availabilityRoute(server)
createRoute(server)
getRoute(server)
updateRoute(server)
blockRoute(server)
unblockRoute(server)
activateRoute(server)
listRoute(server)
membersRoute(server)
createInvitationRoute(server)
logsRoute(server)
}
export default plugin

48
src/plugins/api/posts.ts

@ -112,11 +112,7 @@ function doPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
const ancestryContainer = containerFor(server.database.client, 'Ancestry')
const userContainer = containerFor(server.database.client, 'Users')
const viewer = await getItem<User>({
container: userContainer,
id: request.viewer.id,
logger: request.log
})
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!viewer) return serverError(reply)
if (viewer.pending) return badRequestError(reply, 'User requires approval')
@ -125,19 +121,13 @@ function doPostRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
const postId = createPostId()
if (request.body.parent) {
const parent = await getItem<Post>({
container: postContainer,
id: request.body.parent,
logger: request.log
})
const parent = await getItem<Post>({ container: postContainer, id: request.body.parent })
if (!parent) return badRequestFormError(reply, 'parent', 'Invalid parent')
const parentRelationship = await getItem<PostRelationship>({
container: ancestryContainer,
id: request.body.parent,
partitionKey: parent.root,
logger: request.log
})
const parents = parentRelationship ? parentRelationship.parents : []
@ -238,11 +228,7 @@ function postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, Serve
const id = normalize(request.params.id)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<User>({
container: userContainer,
id,
logger: request.log
})
const user = await getItem<User>({ container: userContainer, id })
if (!user) return notFoundError(reply)
if (!user.group) return notFoundError(reply)
@ -261,12 +247,7 @@ function postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, Serve
case UserPrivacyType.Group: {
if (!request.viewer) return unauthorizedError(reply)
const viewer = await getItem<User>({
container: userContainer,
id: request.viewer.id,
logger: request.log
})
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
@ -278,12 +259,7 @@ function postsByUserRoute(server: FastifyInstance<Server, IncomingMessage, Serve
}
if (request.viewer) {
const viewer = await getItem<User>({
container: userContainer,
id: request.viewer.id,
logger: request.log
})
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
@ -354,12 +330,7 @@ function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
if (!server.database) return serverError(reply)
const postContainer = containerFor(server.database.client, 'Posts')
const post = await getItem<Post>({
container: postContainer,
id: request.params.id,
logger: request.log
})
const post = await getItem<Post>({ container: postContainer, id: request.params.id })
if (!post) return notFoundError(reply)
const query = createQuerySpec('SELECT * FROM Ancestry a WHERE a.pk = @pk AND ARRAY_CONTAINS(a.parents, @id)', {
@ -400,12 +371,7 @@ function postRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespon
const users = await getUsers(server.database.client, userIds, request.log)
if (request.viewer) {
const viewer = await getItem<User>({
container: containerFor(server.database.client, 'Users'),
id: request.viewer.id,
logger: request.log
})
const viewer = await getItem<User>({ container: containerFor(server.database.client, 'Users'), id: request.viewer.id })
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)

40
src/plugins/api/users.ts

@ -68,7 +68,6 @@ function availabilityRoute(server: FastifyInstance<Server, IncomingMessage, Serv
const user = await getItem<User>({
container: containerFor(server.database.client, 'Users'),
id,
logger: request.log,
})
return {
@ -115,8 +114,7 @@ function updateRoute(server: FastifyInstance<Server, IncomingMessage, ServerResp
if (!request.viewer) return unauthorizedError(reply)
const viewerItem = containerFor(server.database.client, 'Users').item(request.viewer.id, request.viewer.id)
const { resource: viewer, requestCharge } = await viewerItem.read<User>()
request.log.trace('Get: %d', requestCharge)
const { resource: viewer } = await viewerItem.read<User>()
if (!viewer) return serverError(reply)
@ -172,21 +170,11 @@ function getRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespons
if (!server.database) return serverError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<User>({
container: userContainer,
id: request.params.id,
logger: request.log
})
const user = await getItem<User>({ container: userContainer, id: request.params.id })
if (!user) return notFoundError(reply)
if (request.viewer) {
const viewer = await getItem<User>({
container: userContainer,
id: request.viewer.id,
logger: request.log
})
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!viewer) return serverError(reply)
if (!viewer.group) return unauthorizedError(reply)
@ -224,8 +212,8 @@ function subscribeRoute(server: FastifyInstance<Server, IncomingMessage, ServerR
if (request.viewer.id === request.params.id) return badRequestError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<User>({ container: userContainer, id: request.params.id, logger: request.log })
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id, logger: request.log })
const user = await getItem<User>({ container: userContainer, id: request.params.id })
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!user) return notFoundError(reply)
if (!viewer) return serverError(reply)
@ -305,8 +293,8 @@ function unsubscribeRoute(server: FastifyInstance<Server, IncomingMessage, Serve
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<User>({ container: userContainer, id: request.params.id, logger: request.log })
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id, logger: request.log })
const user = await getItem<User>({ container: userContainer, id: request.params.id })
const viewer = await getItem<User>({ container: userContainer, id: request.viewer.id })
if (!user) return notFoundError(reply)
if (!viewer) return serverError(reply)
@ -360,12 +348,7 @@ function blockRoute(server: FastifyInstance<Server, IncomingMessage, ServerRespo
if (!request.viewer) return unauthorizedError(reply)
const userContainer = containerFor(server.database.client, 'Users')
const user = await getItem<User>({
container: userContainer,
id: request.params.id,
logger: request.log
})
const user = await getItem<User>({ container: userContainer, id: request.params.id })
if (!user) return notFoundError(reply)
if (!user.group) return badRequestError(reply)
@ -413,12 +396,7 @@ function unblockRoute(server: FastifyInstance<Server, IncomingMessage, ServerRes
const userContainer = containerFor(server.database.client, 'Users')
const groupContainer = containerFor(server.database.client, 'Groups')
const user = await getItem<User>({
container: userContainer,
id: request.params.id,
logger: request.log
})
const user = await getItem<User>({ container: userContainer, id: request.params.id })
if (!user) return notFoundError(reply)
if (!user.group) return badRequestError(reply, 'Invalid operation')

14
src/types/collections.ts

@ -54,6 +54,7 @@ export enum GroupItemType {
Report = 'report',
Block = 'block',
Invitation = 'invitation',
Log = 'log',
}
export enum GroupMembershipType {
@ -130,8 +131,8 @@ export interface GroupInvitation {
pk: string // Group ID
t: GroupItemType.Invitation
userId: string
limit: number
expiration: number
limit?: number
expiration?: number
uses: number
active: boolean
created: number
@ -157,6 +158,15 @@ export interface GroupBlock {
created: number
}
export interface GroupLog {
id?: string
pk: string // Group ID
t: GroupItemType.Log
userId: string
content: string
created: number
}
export interface User {
id: string
pk: string // ID

Loading…
Cancel
Save