added tenant + graph support

This commit is contained in:
Wyatt Johnson
2018-06-16 17:21:04 -06:00
parent 02e1236792
commit 4318e1ddbe
35 changed files with 1024 additions and 269 deletions
+5 -1
View File
@@ -1,3 +1,7 @@
{
"schemaPath": "src/core/server/graph/schema/schema.graphql"
"projects": {
"tenant": {
"schemaPath": "src/core/server/graph/tenant/schema/schema.graphql"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# HTTP Routes
## Stream API
/api/tenant/:tenantID/graphql
/api/tenant/:tenantID/auth
## Tenant Management API
/api/graphql
/api/auth
# Folder structure
/graph/tenant <-- tenant's api (comments, assets, ...)
/graph/management <-- tenant management api
1. No tenants
2. Create a tenant <-- consuming the TMA
+11
View File
@@ -119,6 +119,12 @@
"integrity": "sha512-hop8SdPUEzbcJm6aTsmuwjIYQo1tqLseKCM+s2bBqTU2gErwI4fE+aqUVOlscPSQbKHKgtMMPoC+h4AIGOJYvw==",
"dev": true
},
"@types/luxon": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-0.5.3.tgz",
"integrity": "sha512-YiVw0M9q9CeynfRKhZYaX2/aCXlCIBpM4eARlPXdv+XVoGVb5iPFaZIlKiMUJ8eWKOhlqi8U6GvOAn8yhR4//Q==",
"dev": true
},
"@types/mime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz",
@@ -2400,6 +2406,11 @@
"yallist": "^2.1.2"
}
},
"luxon": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.2.1.tgz",
"integrity": "sha512-ymX+7rWJjYw6jfmtkLqHJmXo+FYC69icT60x+utlzjIOc/U4SNXljUITwH4C1RDP0ZukWf4apHT/d1Ux/4eHPg=="
},
"make-dir": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
+3
View File
@@ -25,6 +25,7 @@
"ioredis": "^3.2.2",
"joi": "^13.4.0",
"lodash": "^4.17.10",
"luxon": "^1.2.1",
"mongodb": "^3.0.10",
"performance-now": "^2.1.0",
"uuid": "^3.2.1"
@@ -38,8 +39,10 @@
"@types/ioredis": "^3.2.8",
"@types/joi": "^13.0.8",
"@types/lodash": "^4.14.109",
"@types/luxon": "^0.5.3",
"@types/mongodb": "^3.0.19",
"@types/uuid": "^3.4.3",
"graphql-playground-html": "^1.6.0",
"graphql-playground-middleware-express": "^1.7.0",
"nodemon": "^1.17.5",
"prettier": "^1.13.4",
+50 -15
View File
@@ -1,16 +1,59 @@
import express, { Express } from 'express';
import express, { Express, Router } from 'express';
import { Db } from 'mongodb';
import { Config } from 'talk-server/config';
import schema from 'talk-server/graph/tenant/schema';
import { create } from 'talk-server/services/mongodb';
import serveStatic from './middleware/serveStatic';
import graphql from './middleware/graphql';
import graphiql from './middleware/graphiql';
import playground from './middleware/playground';
import {
access as accessLogger,
error as errorLogger,
} from './middleware/logging';
import tenantGraphMiddleware from 'talk-server/graph/tenant/middleware';
import schema from 'talk-server/graph/schema';
import { create } from 'talk-server/services/mongodb';
async function createTenantRouter(config: Config, db: Db): Promise<Router> {
const router = express.Router({ mergeParams: true });
if (config.get('env') === 'development') {
// GraphiQL
router.get(
'/graphiql',
playground(req => ({
endpoint: `/api/tenant/${req.params.tenantID}/graphql`,
}))
);
}
// Tenant API
router.use('/graphql', express.json(), tenantGraphMiddleware(db));
return router;
}
async function createAPIRouter(config: Config, db: Db): Promise<Router> {
// Create a router.
const router = express.Router({ mergeParams: true });
// Configure the tenant routes.
router.use('/tenant/:tenantID', await createTenantRouter(config, db));
return router;
}
async function createRouter(config: Config): Promise<Router> {
// Setup MongoDB.
const db = await create(config);
// Create a router.
const router = express.Router({ mergeParams: true });
router.use('/api', await createAPIRouter(config, db));
return router;
}
/**
* createApp will create a Talk Express app that can be used to handle requests.
@@ -27,16 +70,8 @@ export async function createApp(
// Static Files
app.use(serveStatic);
if (config.get('env') === 'development') {
// GraphiQL
app.get('/graphiql', graphiql());
}
// Setup MongoDB.
const db = await create(config);
// API
app.use('/api/graphql', express.json(), graphql({ schema, db }));
// Mount the router.
app.use(await createRouter(config));
// Error Handling
app.use(errorLogger);
@@ -1,3 +0,0 @@
import playground from 'graphql-playground-middleware-express';
export default () => playground({ endpoint: '/api/graphql' });
-15
View File
@@ -1,15 +0,0 @@
import { graphqlExpress } from 'apollo-server-express';
import { GraphQLSchema } from 'graphql';
import Context from 'talk-server/graph/context';
import { Db } from 'mongodb';
export interface GraphQLOptions {
schema: GraphQLSchema;
db: Db;
}
export default (opts: GraphQLOptions) =>
graphqlExpress(req => ({
schema: opts.schema,
context: new Context({ db: opts.db, req }),
}));
@@ -0,0 +1,16 @@
import { Request, RequestHandler } from 'express';
import { MiddlewareOptions } from 'graphql-playground-html';
import playground from 'graphql-playground-middleware-express';
export type PlaygroundFn = (req: Request) => MiddlewareOptions;
export default (fn: PlaygroundFn): RequestHandler => (req, res, next) => {
// Generate the options.
const options: MiddlewareOptions = fn(req);
// Create the playground handler.
const handler = playground(options);
// Execute it.
handler(req, res, next);
};
+52 -52
View File
@@ -8,65 +8,65 @@ dotenv.config();
// Add custom format for the mongo uri scheme.
convict.addFormat({
name: 'mongo-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['mongodb'],
})
);
},
name: 'mongo-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['mongodb'],
})
);
},
});
// Add custom format for the redis uri scheme.
convict.addFormat({
name: 'redis-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['redis'],
})
);
},
name: 'redis-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['redis'],
})
);
},
});
export const config = convict({
env: {
doc: 'The application environment.',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV',
},
port: {
doc: 'The port to bind.',
format: 'port',
default: 3000,
env: 'PORT',
arg: 'port',
},
mongodb: {
doc: 'The MongoDB database to connect to.',
format: 'mongo-uri',
default: 'mongodb://localhost/talk',
env: 'MONGODB',
arg: 'mongodb',
},
redis: {
doc: 'The Redis database to connect to.',
format: 'redis-uri',
default: 'redis://localhost:6379',
env: 'REDIS',
arg: 'redis',
},
secret: {
doc: 'The secret used to sign and verify JWTs',
format: '*',
default: null,
env: 'SECRET',
arg: 'secret',
},
env: {
doc: 'The application environment.',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV',
},
port: {
doc: 'The port to bind.',
format: 'port',
default: 3000,
env: 'PORT',
arg: 'port',
},
mongodb: {
doc: 'The MongoDB database to connect to.',
format: 'mongo-uri',
default: 'mongodb://localhost/talk',
env: 'MONGODB',
arg: 'mongodb',
},
redis: {
doc: 'The Redis database to connect to.',
format: 'redis-uri',
default: 'redis://localhost:6379',
env: 'REDIS',
arg: 'redis',
},
secret: {
doc: 'The secret used to sign and verify JWTs',
format: '*',
default: null,
env: 'SECRET',
arg: 'secret',
},
});
export type Config = typeof config;
-13
View File
@@ -1,13 +0,0 @@
import DataLoader from 'dataloader';
import {
Asset,
retrieveMany as retrieveManyAssets,
} from 'talk-server/models/asset';
import Context from 'talk-server/graph/context';
const loadAssets = async (ctx: Context, ids: string[]): Promise<Array<Asset>> =>
retrieveManyAssets(ctx.db, ids);
export default (ctx: Context) => ({
asset: new DataLoader<string, Asset>(ids => loadAssets(ctx, ids)),
});
-4
View File
@@ -1,4 +0,0 @@
import Asset from './asset';
import Context from 'talk-server/graph/context';
export default (ctx: Context) => ({ Asset: Asset(ctx) });
-5
View File
@@ -1,5 +0,0 @@
import query from './query';
export default {
Query: query,
};
@@ -1,17 +1,19 @@
import loaders from './loaders';
import { Request } from 'express';
import { Db } from 'mongodb';
import { Tenant } from 'talk-server/models/tenant';
export interface ContextOptions {
req: Request;
tenant?: Tenant;
db: Db;
}
export default class Context {
export default class TenantContext {
public loaders: ReturnType<typeof loaders>;
public db: Db;
public tenant?: Tenant;
constructor({ req, db }: ContextOptions) {
constructor({ tenant, db }: ContextOptions) {
this.tenant = tenant;
this.loaders = loaders(this);
this.db = db;
}
@@ -0,0 +1,12 @@
import DataLoader from 'dataloader';
import {
Asset,
retrieveMany as retrieveManyAssets,
} from 'talk-server/models/asset';
import Context from 'talk-server/graph/tenant/context';
export default (ctx: Context) => ({
asset: new DataLoader<string, Asset>(ids =>
retrieveManyAssets(ctx.db, ctx.tenant.id, ids)
),
});
@@ -0,0 +1,25 @@
import DataLoader from 'dataloader';
import {
Comment,
retrieveMany,
retrieveAssetConnection,
ConnectionInput,
retrieveRepliesConnection,
} from 'talk-server/models/comment';
import Context from 'talk-server/graph/tenant/context';
export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
),
forAsset: (assetID: string, input: ConnectionInput) =>
retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input),
forParent: (assetID: string, parentID: string, input: ConnectionInput) =>
retrieveRepliesConnection(
ctx.db,
ctx.tenant.id,
assetID,
parentID,
input
),
});
@@ -0,0 +1,10 @@
import Assets from './assets';
import Comments from './comments';
import Users from './users';
import Context from 'talk-server/graph/tenant/context';
export default (ctx: Context) => ({
Assets: Assets(ctx),
Comments: Comments(ctx),
Users: Users(ctx),
});
@@ -0,0 +1,9 @@
import DataLoader from 'dataloader';
import { User, retrieveMany } from 'talk-server/models/user';
import Context from 'talk-server/graph/tenant/context';
export default (ctx: Context) => ({
user: new DataLoader<string, User>(ids =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
),
});
@@ -0,0 +1,13 @@
import { graphqlExpress } from 'apollo-server-express';
import schema from './schema';
import TenantContext from './context';
import { Db } from 'mongodb';
import { Tenant } from 'talk-server/models/tenant';
export default (db: Db) =>
graphqlExpress(async req => {
return {
schema,
context: new TenantContext({ db, tenant: { id: '1' } as Tenant }),
};
});
@@ -0,0 +1,8 @@
import { Asset } from 'talk-server/models/asset';
import Context from 'talk-server/graph/tenant/context';
import { ConnectionInput } from 'talk-server/models/comment';
export default {
comments: async (asset: Asset, input: ConnectionInput, ctx: Context) =>
ctx.loaders.Comments.forAsset(asset.id, input),
};
@@ -0,0 +1,9 @@
import { Comment, ConnectionInput } from 'talk-server/models/comment';
import Context from 'talk-server/graph/tenant/context';
export default {
author: async (comment: Comment, _: any, ctx: Context) =>
ctx.loaders.Users.user.load(comment.author_id),
replies: async (comment: Comment, input: ConnectionInput, ctx: Context) =>
ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input),
};
@@ -0,0 +1,72 @@
import { DateTime } from 'luxon';
import { GraphQLScalarType } from 'graphql';
import { Kind } from 'graphql/language';
import { Cursor } from 'talk-server/models/connection';
function parseIntegerCursor(value: string): number {
try {
const cursor = parseInt(value);
return cursor;
} catch (err) {
return null;
}
}
function parseCursor(value: string): Cursor {
if (value.endsWith('Z')) {
const date = DateTime.fromISO(value, {});
if (!date.isValid) {
return parseIntegerCursor(value);
}
return date.toJSDate();
}
return parseIntegerCursor(value);
}
export default new GraphQLScalarType({
name: 'Cursor',
description: 'Cursor represents a paginating cursor.',
serialize(value) {
switch (typeof value) {
case 'object':
if (value instanceof Date) {
return value.toISOString();
} else if (value instanceof DateTime) {
return value.toISO();
}
return null;
case 'number':
return value.toString();
case 'string':
return value;
default:
return null;
}
},
parseValue(value) {
if (typeof value === 'string') {
return parseCursor(value);
}
return null;
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
// This handles an empty string.
if (!ast.value || ast.value.length === 0) {
return null;
}
return parseCursor(ast.value);
case Kind.INT:
return parseIntegerCursor(ast.value);
default:
return null;
}
},
});
@@ -0,0 +1,11 @@
import Asset from './asset';
import Comment from './comment';
import Cursor from './cursor';
import Query from './query';
export default {
Asset,
Comment,
Cursor,
Query,
};
@@ -1,4 +1,4 @@
import Context from 'talk-server/graph/context';
import Context from 'talk-server/graph/tenant/context';
import { Asset } from 'talk-server/models/asset';
export default {
@@ -7,6 +7,6 @@ export default {
{ id, url }: { id?: string; url: string },
ctx: Context
): Promise<Asset> => {
return ctx.loaders.Asset.asset.load(id);
return ctx.loaders.Assets.asset.load(id);
},
};
@@ -2,11 +2,11 @@ import {
addMockFunctionsToSchema,
addResolveFunctionsToSchema,
} from 'graphql-tools';
import resolvers from 'talk-server/graph/resolvers';
import resolvers from 'talk-server/graph/tenant/resolvers';
import { getGraphQLProjectConfig } from 'graphql-config';
// Load the configuration from the provided `.graphqlconfig` file.
const config = getGraphQLProjectConfig();
const config = getGraphQLProjectConfig(__dirname, 'tenant');
// Get the GraphQLSchema from the configuration.
const schema = config.getSchema();
@@ -221,7 +221,11 @@ type Comment {
"""
replies will return the replies to this comment.
"""
replies(cursor: Cursor, limit: Int = 10): CommentsConnection
replies(
first: Int = 10
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
): CommentsConnection
}
type PageInfo {
@@ -253,7 +257,7 @@ type CommentEdge {
"""
"""
id: ID
cursor: Cursor
}
"""
@@ -276,9 +280,10 @@ type CommentsConnection {
################################################################################
enum COMMENT_SORT {
CREATED_AT
REPLIES
RESPECT
CREATED_AT_DESC
CREATED_AT_ASC
REPLIES_DESC
RESPECT_DESC
}
"""
@@ -305,7 +310,7 @@ type Asset {
"""
comments(
first: Int = 10
orderBy: COMMENT_SORT = CREATED_AT
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
): CommentsConnection
@@ -356,11 +361,13 @@ type AssetsConnection {
}
################################################################################
## Queries
## Query
################################################################################
# Query is every query possible against this GraphQL server.
type Query {
"""
comment returns a specific comment.
"""
comment(id: ID!): Comment
"""
@@ -377,6 +384,11 @@ type Query {
me is the current logged in User.
"""
me: User
"""
settings is the Settings for a given Tenant.
"""
settings: Settings!
}
################################################################################
+3
View File
@@ -0,0 +1,3 @@
export interface ActionCounts {
[_: string]: number;
}
+33 -20
View File
@@ -1,11 +1,16 @@
import { Db } from 'mongodb';
import { FilterQuery } from './types';
import { Db, Collection } from 'mongodb';
import Query, { FilterQuery } from './query';
import { defaults } from 'lodash';
import uuid from 'uuid';
import { Omit } from 'talk-common/types';
import dotize from 'dotize';
import { TenantResource } from 'talk-server/models/tenant';
export interface Asset {
function collection(db: Db): Collection<Asset> {
return db.collection<Asset>('assets');
}
export interface Asset extends TenantResource {
readonly id: string;
url: string;
scraped?: Date;
@@ -24,21 +29,28 @@ export interface Asset {
export type CreateAssetInput = Pick<Asset, 'id' | 'url'>;
export async function create(db: Db, input: CreateAssetInput): Promise<Asset> {
export async function create(
db: Db,
tenantID: string,
input: CreateAssetInput
): Promise<Asset> {
const now = new Date();
// Construct the filter.
const filter: FilterQuery<Asset> = {};
const query = new Query<Asset>(collection(db)).where({
tenant_id: tenantID,
});
if (input.id) {
filter.id = input.id;
query.where({ id: input.id });
} else {
filter.url = input.url;
query.where({ url: input.url });
}
// Craft the update object.
const update: { $setOnInsert: Asset } = {
$setOnInsert: defaults(input, {
id: uuid.v4(),
tenant_id: tenantID,
created_at: now,
}),
};
@@ -46,7 +58,7 @@ export async function create(db: Db, input: CreateAssetInput): Promise<Asset> {
// Perform the upsert operation.
const result = await db
.collection<Asset>('assets')
.findOneAndUpdate(filter, update, {
.findOneAndUpdate(query.filter, update, {
// Create the object if it doesn't already exist.
upsert: true,
// False to return the updated document instead of the original
@@ -57,24 +69,24 @@ export async function create(db: Db, input: CreateAssetInput): Promise<Asset> {
return result.value;
}
export async function exists(db: Db, id: string): Promise<boolean> {
// TODO: implement
// const cursor = await db.collection<Asset>('assets').find({ id }).limit(1);
return null;
}
export async function retrieve(db: Db, id: string): Promise<Asset> {
return await db.collection<Asset>('assets').findOne({ id });
export async function retrieve(
db: Db,
tenantID: string,
id: string
): Promise<Asset> {
return await db
.collection<Asset>('assets')
.findOne({ id, tenant_id: tenantID });
}
export async function retrieveMany(
db: Db,
tenantID: string,
ids: string[]
): Promise<Array<Asset>> {
const cursor = await db
.collection<Asset>('assets')
.find({ id: { $in: ids } });
.find({ id: { $in: ids }, tenant_id: tenantID });
const assets = await cursor.toArray();
@@ -83,16 +95,17 @@ export async function retrieveMany(
export type UpdateAssetInput = Omit<
Partial<Asset>,
'id' | 'url' | 'created_at'
'id' | 'tenant_id' | 'url' | 'created_at'
>;
export async function update(
db: Db,
tenantID: string,
id: string,
update: UpdateAssetInput
): Promise<Readonly<Asset>> {
const result = await db.collection<Asset>('assets').findOneAndUpdate(
{ id },
{ id, tenant_id: tenantID },
// Only update fields that have been updated.
{ $set: dotize(update) },
// False to return the updated document instead of the original
+209 -12
View File
@@ -1,7 +1,15 @@
import { Db } from 'mongodb';
import { Db, Collection } from 'mongodb';
import { Omit, Sub } from 'talk-common/types';
import { merge } from 'lodash';
import uuid from 'uuid';
import { Connection, Edge, Cursor } from 'talk-server/models/connection';
import Query from 'talk-server/models/query';
import { ActionCounts } from 'talk-server/models/actions';
import { TenantResource } from 'talk-server/models/tenant';
function collection(db: Db): Collection<Comment> {
return db.collection<Comment>('comments');
}
export interface BodyHistoryItem {
body: string;
@@ -22,11 +30,7 @@ export enum CommentStatus {
NONE = 'NONE',
}
export interface ActionCounts {
[_: string]: number;
}
export interface Comment {
export interface Comment extends TenantResource {
readonly id: string;
parent_id?: string;
author_id: string;
@@ -39,15 +43,24 @@ export interface Comment {
reply_count: number;
created_at: Date;
deleted_at?: Date;
metadata?: {
[_: string]: any;
};
}
export type CreateCommentInput = Omit<
Comment,
'id' | 'created_at' | 'reply_count' | 'body_history' | 'status_history'
| 'id'
| 'tenant_id'
| 'created_at'
| 'reply_count'
| 'body_history'
| 'status_history'
>;
export async function create(
db: Db,
tenantID: string,
input: CreateCommentInput
): Promise<Readonly<Comment>> {
const now = new Date();
@@ -59,6 +72,7 @@ export async function create(
// created.
const defaults: Sub<Comment, CreateCommentInput> = {
id: uuid.v4(),
tenant_id: tenantID,
created_at: now,
reply_count: 0,
body_history: [
@@ -83,17 +97,200 @@ export async function create(
// TODO: Check for existence of the asset ID before we create the comment.
// Insert it into the database.
await db.collection<Comment>('comments').insertOne(comment);
await collection(db).insertOne(comment);
// TODO: update reply count of parent if exists.
return comment;
}
async function incrementReplyCount(db: Db, parentID: string): Promise<void> {
return null;
export async function retrieve(
db: Db,
tenantID: string,
id: string
): Promise<Readonly<Comment>> {
return collection(db).findOne({ id, tenant_id: tenantID });
}
export async function retrieve(db: Db, id: string): Promise<Comment> {
return null;
export async function retrieveMany(
db: Db,
tenantID: string,
ids: string[]
): Promise<Readonly<Comment>[]> {
const cursor = await collection(db).find({
id: {
$in: ids,
},
tenant_id: tenantID,
});
const comments = await cursor.toArray();
return ids.map(id => comments.find(comment => comment.id === id));
}
export enum CommentSort {
CREATED_AT_DESC = 'CREATED_AT_DESC',
CREATED_AT_ASC = 'CREATED_AT_ASC',
REPLIES_DESC = 'REPLIES_DESC',
RESPECT_DESC = 'RESPECT_DESC',
}
export interface ConnectionInput {
first: number;
orderBy: CommentSort;
after?: Cursor;
}
/**
* nodesToEdge converts a set of nodes and configuration options into a set of
* edges.
*
* @param input connection configuration
* @param nodes nodes returned from the query
*/
function nodesToEdge(
input: ConnectionInput,
nodes: Comment[]
): Edge<Comment>[] {
let getCursor: (comment: Comment, index: number) => Cursor;
switch (input.orderBy) {
case CommentSort.CREATED_AT_DESC:
case CommentSort.CREATED_AT_ASC:
getCursor = comment => comment.created_at;
break;
case CommentSort.REPLIES_DESC:
case CommentSort.RESPECT_DESC:
getCursor = (_, index) =>
(input.after ? (input.after as number) : 0) + index + 1;
break;
}
return nodes.map((comment, index) => ({
node: comment,
cursor: getCursor(comment, index),
}));
}
/**
* retrieveRepliesConnection returns a Connection<Comment> for a given comments
* replies.
*
* @param db database connection
* @param parentID the parent id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveRepliesConnection(
db: Db,
tenantID: string,
assetID: string,
parentID: string,
input: ConnectionInput
): Promise<Readonly<Connection<Comment>>> {
// Create the query.
const query = new Query(collection(db)).where({
tenant_id: tenantID,
asset_id: assetID,
parent_id: parentID,
});
// Return a connection for the comments query.
return retrieveConnection(input, query);
}
/**
* retrieveAssetConnection returns a Connection<Comment> for a given Asset's
* comments.
*
* @param db database connection
* @param assetID the Asset id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveAssetConnection(
db: Db,
tenantID: string,
assetID: string,
input: ConnectionInput
): Promise<Readonly<Connection<Comment>>> {
// Create the query.
const query = new Query(collection(db)).where({
tenant_id: tenantID,
asset_id: assetID,
parent_id: null,
});
// Return a connection for the comments query.
return retrieveConnection(input, query);
}
/**
* retrieveConnection returns a Connection<Comment> for the given input and
* Query.
*
* @param input connection configuration
* @param query the Query for the set of nodes that should have the connection
* configuration applied
*/
async function retrieveConnection(
input: ConnectionInput,
query: Query<Comment>
): Promise<Readonly<Connection<Comment>>> {
// Apply some sorting options.
switch (input.orderBy) {
case CommentSort.CREATED_AT_DESC:
query.orderBy({ created_at: -1 });
if (input.after) {
query.where({ created_at: { $lt: input.after as Date } });
}
break;
case CommentSort.CREATED_AT_ASC:
query.orderBy({ created_at: 1 });
if (input.after) {
query.where({ created_at: { $gt: input.after as Date } });
}
break;
case CommentSort.REPLIES_DESC:
query.orderBy({ reply_count: -1, created_at: -1 });
if (input.after) {
query.after(input.after as number);
}
break;
case CommentSort.RESPECT_DESC:
query.orderBy({ 'action_counts.respect': -1, created_at: -1 });
if (input.after) {
query.after(input.after as number);
}
break;
}
// We load one more than the limit so we can determine if there is
// another page of entries.
query.first(input.first + 1);
// Get the cursor.
const cursor = await query.exec();
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// The hasNextPage is always handled the same (ask for one more than we need,
// if there is one more, than there is more).
let hasNextPage = false;
if (input.first >= 0 && nodes.length > input.first) {
// There was one more than we expected! Set hasNextPage = true and remove
// the last item from the array that we requested.
hasNextPage = true;
nodes.splice(input.first, 1);
}
// Convert the nodes to edges.
const edges = nodesToEdge(input, nodes);
// Return the connection.
return {
edges,
pageInfo: {
hasNextPage,
},
};
}
+15
View File
@@ -0,0 +1,15 @@
export type Cursor = Date | number | string;
export interface Edge<T> {
node: T;
cursor: Cursor;
}
export interface PageInfo {
hasNextPage: boolean;
}
export interface Connection<T> {
edges: Edge<T>[];
pageInfo: PageInfo;
}
+92
View File
@@ -0,0 +1,92 @@
import { merge } from 'lodash';
import { Collection, Cursor } from 'mongodb';
import { FilterQuery as MongoFilterQuery } from 'mongodb';
import { Writeable } from '../../common/types';
/**
* FilterQuery<T> ensures that given the type T, that the FilterQuery will be a
* writeable, partial set of properties while also including MongoDB specific
* properties (like $lt, or $gte).
*/
export type FilterQuery<T> = MongoFilterQuery<Writeable<Partial<T>>>;
/**
* Query is a convenience class used to wrap the existing MongoDB driver to
* provide easier complex query management.
*/
export default class Query<T> {
public filter: FilterQuery<T>;
private collection: Collection<T>;
private skip?: number;
private limit?: number;
private sort?: Object;
constructor(collection: Collection<T>) {
this.collection = collection;
}
/**
* where will merge the given filter into the existing query.
*
* @param filter the filter to merge into the existing query
*/
public where(filter: FilterQuery<T>): Query<T> {
this.filter = merge({}, this.filter || {}, filter);
return this;
}
/**
* after will skip the indicated number of documents.
*
* @param skip the number of documents to skip
*/
public after(skip: number): Query<T> {
this.skip = skip;
return this;
}
/**
* first will limit to the indicated number of documents.
*
* @param limit the number of documents to limit the result to
*/
public first(limit: number): Query<T> {
this.limit = limit;
return this;
}
/**
* orderBy will apply sorting to the query filter when executed.
*
* @param sort the sorting option for the documents
*/
public orderBy(sort: Object): Query<T> {
this.sort = merge({}, this.sort || {}, sort);
return this;
}
/**
* exec will return a cursor to the query.
*/
async exec(): Promise<Cursor<T>> {
let cursor = await this.collection.find(this.filter);
if (this.limit) {
// Apply a limit if it exists.
cursor = cursor.limit(this.limit);
}
if (this.sort) {
// Apply a sort if it exists.
cursor = cursor.sort(this.sort);
}
if (this.skip) {
// Apply a skip if it exists.
cursor = cursor.skip(this.skip);
}
return cursor;
}
}
@@ -1,10 +1,16 @@
import { Db } from 'mongodb';
import { Db, Collection } from 'mongodb';
import { defaultsDeep } from 'lodash';
import dotize from 'dotize';
import uuid from 'uuid';
import { Omit } from 'talk-common/types';
// selector is the single document selector for the Settings model stored in the
// settings collection in MongoDB.
const selector = { id: '1' };
function collection(db: Db): Collection<Tenant> {
return db.collection<Tenant>('tenants');
}
export interface TenantResource {
readonly tenant_id: string;
}
export interface Wordlist {
banned: string[];
@@ -16,7 +22,7 @@ export enum Moderation {
POST = 'POST',
}
export interface Settings {
export interface Tenant {
readonly id: string;
moderation: Moderation;
@@ -49,10 +55,9 @@ export interface Settings {
domains: string[];
}
const defaultSettings: Settings = {
// Include the selector.
...selector,
export type CreateTenantInput = Omit<Tenant, 'id'>;
const defaults: CreateTenantInput = {
// Default to post moderation.
moderation: Moderation.POST,
@@ -76,40 +81,48 @@ const defaultSettings: Settings = {
export async function create(
db: Db,
settingsInput: Partial<Settings>
): Promise<Readonly<Settings>> {
const result = await db
.collection<Settings>('settings')
.findOneAndReplace(
selector,
defaultsDeep({}, settingsInput, defaultSettings),
{
upsert: true,
returnOriginal: false,
}
);
input: Partial<CreateTenantInput>
): Promise<Readonly<Tenant>> {
const tenant = defaultsDeep({ id: uuid.v4() }, input, defaults);
return result.value;
await collection(db).insert(tenant);
return tenant;
}
export async function retrieve(db: Db): Promise<Readonly<Settings>> {
const settings = await db
.collection<Settings>('settings')
.findOne(selector);
if (!settings) {
throw new Error('settings not initialized'); // FIXME: return actual typed error
}
export async function retrieve(db: Db, id: string): Promise<Readonly<Tenant>> {
return collection(db).findOne({ id });
}
return settings;
export async function retrieveMany(
db: Db,
ids: string[]
): Promise<Readonly<Tenant>[]> {
const cursor = await collection(db).find({
id: {
$in: ids,
},
});
const tenants = await cursor.toArray();
return ids.map(id => tenants.find(tenant => tenant.id === id));
}
export async function retrieveAll(db: Db): Promise<Readonly<Tenant>[]> {
return collection(db)
.find({})
.toArray();
}
export async function update(
db: Db,
update: Partial<Settings>
): Promise<Readonly<Settings>> {
// Get the settings from the database.
const result = await db.collection<Settings>('settings').findOneAndUpdate(
selector,
id: string,
update: Partial<CreateTenantInput>
): Promise<Readonly<Tenant>> {
// Get the tenant from the database.
const result = await collection(db).findOneAndUpdate(
{ id },
// Only update fields that have been updated.
{ $set: dotize(update) },
// False to return the updated document instead of the original
-5
View File
@@ -1,5 +0,0 @@
import { FilterQuery } from 'mongodb';
import { Writeable } from '../../common/types';
export type FilterQuery<T> = Writeable<Partial<T>> &
FilterQuery<Writeable<Partial<T>>>;
+181
View File
@@ -0,0 +1,181 @@
import { ActionCounts } from 'talk-server/models/actions';
import { Db, Collection } from 'mongodb';
import uuid from 'uuid';
import { Omit, Sub } from 'talk-common/types';
import { merge } from 'lodash';
import { TenantResource } from 'talk-server/models/tenant';
function collection(db: Db): Collection<User> {
return db.collection<User>('users');
}
export interface Profile {
readonly id: string;
provider: string;
}
export interface Token {
readonly id: string;
name: string;
active: boolean;
}
export enum UserUsernameStatus {
// UNSET is used when the username can be changed, and does not necessarily
// require moderator action to become active. This can be used when the user
// signs up with a social login and has the option of setting their own
// username.
UNSET = 'UNSET',
// SET is used when the username has been set for the first time, but cannot
// change without the username being rejected by a moderator and that moderator
// agreeing that the username should be allowed to change.
SET = 'SET',
// APPROVED is used when the username was changed, and subsequently approved by
// said moderator.
APPROVED = 'APPROVED',
// REJECTED is used when the username was changed, and subsequently rejected by
// said moderator.
REJECTED = 'REJECTED',
// CHANGED is used after a user has changed their username after it was
// rejected.
CHANGED = 'CHANGED',
}
export enum UserRole {
ADMIN = 'ADMIN',
MODERATOR = 'MODERATOR',
STAFF = 'STAFF',
COMMENTER = 'COMMENTER',
}
export interface UserStatusHistory<T> {
status: T; // TODO: migrate field
assigned_by?: string;
reason?: string; // TODO: migrate field
created_at: Date;
}
export interface UserStatusItem<T> {
status: T; // TODO: migrate field
history: UserStatusHistory<T>[];
}
export interface UserStatus {
username: UserStatusItem<UserUsernameStatus>;
banned: UserStatusItem<boolean>;
suspension: UserStatusItem<Date>;
}
export interface User extends TenantResource {
readonly id: string;
username: string;
password?: string;
profiles: Profile[];
tokens: Token[];
role: UserRole;
status: UserStatus;
action_counts: ActionCounts;
ignored_users: string[]; // TODO: migrate field
created_at: Date;
}
export type CreateUserInput = Omit<
User,
| 'id'
| 'tenant_id'
| 'tokens'
| 'status'
| 'role'
| 'action_counts'
| 'ignored_users'
| 'created_at'
>;
export async function create(
db: Db,
tenantID: string,
input: CreateUserInput
): Promise<Readonly<User>> {
const now = new Date();
// // Pull out some useful properties from the input.
// const { body, status } = input;
// default are the properties set by the application when a new user is
// created.
const defaults: Sub<User, CreateUserInput> = {
id: uuid.v4(),
tenant_id: tenantID,
role: UserRole.COMMENTER,
tokens: [],
action_counts: {},
ignored_users: [],
status: {
banned: {
status: false,
history: [],
},
suspension: {
status: null,
history: [],
},
username: {
status: UserUsernameStatus.SET,
history: [],
},
},
created_at: now,
};
// Merge the defaults and the input together.
const user: User = merge({}, defaults, input);
// Insert it into the database.
await collection(db).insertOne(user);
return user;
}
export async function retrieve(
db: Db,
tenantID: string,
id: string
): Promise<Readonly<User>> {
return collection(db).findOne({ id, tenant_id: tenantID });
}
export async function retrieveMany(
db: Db,
tenantID: string,
ids: string[]
): Promise<Readonly<User>[]> {
const cursor = await collection(db).find({
id: {
$in: ids,
},
tenant_id: tenantID,
});
const users = await cursor.toArray();
return ids.map(id => users.find(comment => comment.id === id));
}
export async function updateRole(
db: Db,
tenantID: string,
id: string,
role: UserRole
): Promise<Readonly<User>> {
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
{ $set: { role } },
{ returnOriginal: false }
);
return result.value;
}
@@ -1,74 +0,0 @@
import { Db } from 'mongodb';
import { Redis } from 'ioredis';
import {
Settings,
retrieve as retrieveSettings,
} from 'talk-server/models/settings';
// Cache provides an interface for retrieving settings stored in local memory
// rather than grabbing it from the database every single call.
export default class Cache {
private value: Promise<Readonly<Settings>>;
constructor(db: Db, subscriber: Redis) {
// Retrieve the settings from the database, and keep them cached in this
// promise.
this.value = retrieveSettings(db).then(settings => settings);
// Subscribe to settings notifications.
subscriber.subscribe('settings');
// Attach to messages on this connection so we can receive updates when
// the settings are changed.
subscriber.on('message', this.onMessage);
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
private onMessage = async (channel: string, message: string) => {
// Only do things when the message is for settings.
if (channel !== 'settings') {
return;
}
try {
// Updated settings come from the messages.
const settings: Settings = JSON.parse(message);
// Update the settings cache.
this.value = new Promise(resolve => resolve(settings));
} catch (err) {
// FIXME: handle the error
}
};
/**
* retrieve returns a promise that will resolve to the settings for Talk.
*/
public async retrieve(): Promise<Readonly<Settings>> {
return this.value;
}
/**
* update will update the value for Settings in the local cache and publish
* a change notification that will be used to keep the other nodes in sync.
*
* @param conn a redis connection used to publish the change notification
* @param settings the updated Settings object
*/
public async update(
conn: Redis,
settings: Settings
): Promise<Readonly<Settings>> {
// Update the settings in the local cache.
this.value = new Promise(resolve => resolve(settings));
// Notify the other nodes about the settings change.
await conn.publish('settings', JSON.stringify(settings));
// Return the settings that were set.
return settings;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { Db } from 'mongodb';
import { Redis } from 'ioredis';
import DataLoader from 'dataloader';
import { Tenant, retrieveAll, retrieveMany } from 'talk-server/models/tenant';
const CacheUpdateChannel = 'tenant';
// Cache provides an interface for retrieving tenant stored in local memory
// rather than grabbing it from the database every single call.
export default class Cache {
// private tenants: Map<string, Promise<Readonly<Tenant>>>;
private tenants: DataLoader<string, Readonly<Tenant>>;
private db: Db;
constructor(db: Db, subscriber: Redis) {
// Save the Db reference.
this.db = db;
// Prepare the list of all tenant's maintained by this instance.
this.tenants = new DataLoader(ids => retrieveMany(db, ids));
// Subscribe to tenant notifications.
subscriber.subscribe(CacheUpdateChannel);
// Attach to messages on this connection so we can receive updates when
// the tenant are changed.
subscriber.on('message', this.onMessage);
}
/**
* primeAll will load all the tenants into the cache on startup.
*/
public async primeAll() {
// Grab all the tenants for this node.
const tenants = await retrieveAll(this.db);
// Clear out all the items in the cache.
this.tenants.clearAll();
// Prime the cache with each of these tenants.
tenants.forEach(tenant => this.tenants.prime(tenant.id, tenant));
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
private onMessage = async (
channel: string,
message: string
): Promise<void> => {
// Only do things when the message is for tenant.
if (channel !== CacheUpdateChannel) {
return;
}
try {
// Updated tenant come from the messages.
const tenant: Tenant = JSON.parse(message);
// Update the tenant cache.
this.tenants.clear(tenant.id).prime(tenant.id, tenant);
} catch (err) {
// FIXME: handle the error
}
};
/**
* retrieve returns a promise that will resolve to the tenant for Talk.
*/
public async retrieve(id: string): Promise<Readonly<Tenant>> {
return this.tenants.load(id);
}
/**
* update will update the value for Tenant in the local cache and publish
* a change notification that will be used to keep the other nodes in sync.
*
* @param conn a redis connection used to publish the change notification
* @param tenant the updated Tenant object
*/
public async update(conn: Redis, tenant: Tenant): Promise<void> {
// Update the tenant in the local cache.
this.tenants.clear(tenant.id).prime(tenant.id, tenant);
// Notify the other nodes about the tenant change.
await conn.publish(CacheUpdateChannel, JSON.stringify(tenant));
}
}