diff --git a/.graphqlconfig b/.graphqlconfig index 7554b5bf1..8c7fde676 100644 --- a/.graphqlconfig +++ b/.graphqlconfig @@ -1,3 +1,7 @@ { - "schemaPath": "src/core/server/graph/schema/schema.graphql" + "projects": { + "tenant": { + "schemaPath": "src/core/server/graph/tenant/schema/schema.graphql" + } + } } \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..39e8e3c6f --- /dev/null +++ b/DESIGN.md @@ -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 diff --git a/package-lock.json b/package-lock.json index a4de54bdb..c01e4d9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e35cc56ca..f50adf144 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 2d484e9f6..ba0597fbc 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -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 { + 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 { + // 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 { + // 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); diff --git a/src/core/server/app/middleware/graphiql.ts b/src/core/server/app/middleware/graphiql.ts deleted file mode 100644 index ed3c1f14d..000000000 --- a/src/core/server/app/middleware/graphiql.ts +++ /dev/null @@ -1,3 +0,0 @@ -import playground from 'graphql-playground-middleware-express'; - -export default () => playground({ endpoint: '/api/graphql' }); diff --git a/src/core/server/app/middleware/graphql.ts b/src/core/server/app/middleware/graphql.ts deleted file mode 100644 index 339805dc9..000000000 --- a/src/core/server/app/middleware/graphql.ts +++ /dev/null @@ -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 }), - })); diff --git a/src/core/server/app/middleware/playground.ts b/src/core/server/app/middleware/playground.ts new file mode 100644 index 000000000..5b3bdfa8b --- /dev/null +++ b/src/core/server/app/middleware/playground.ts @@ -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); +}; diff --git a/src/core/server/config.ts b/src/core/server/config.ts index e0e21e1fb..ac093bf44 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -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; diff --git a/src/core/server/graph/loaders/asset.ts b/src/core/server/graph/loaders/asset.ts deleted file mode 100644 index 33788654e..000000000 --- a/src/core/server/graph/loaders/asset.ts +++ /dev/null @@ -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> => - retrieveManyAssets(ctx.db, ids); - -export default (ctx: Context) => ({ - asset: new DataLoader(ids => loadAssets(ctx, ids)), -}); diff --git a/src/core/server/graph/loaders/index.ts b/src/core/server/graph/loaders/index.ts deleted file mode 100644 index 2dbb1a988..000000000 --- a/src/core/server/graph/loaders/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import Asset from './asset'; -import Context from 'talk-server/graph/context'; - -export default (ctx: Context) => ({ Asset: Asset(ctx) }); diff --git a/src/core/server/graph/resolvers/index.ts b/src/core/server/graph/resolvers/index.ts deleted file mode 100644 index 664c8abda..000000000 --- a/src/core/server/graph/resolvers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import query from './query'; - -export default { - Query: query, -}; diff --git a/src/core/server/graph/context.ts b/src/core/server/graph/tenant/context.ts similarity index 53% rename from src/core/server/graph/context.ts rename to src/core/server/graph/tenant/context.ts index 28aa599e3..8ade5435b 100644 --- a/src/core/server/graph/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -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; public db: Db; + public tenant?: Tenant; - constructor({ req, db }: ContextOptions) { + constructor({ tenant, db }: ContextOptions) { + this.tenant = tenant; this.loaders = loaders(this); this.db = db; } diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts new file mode 100644 index 000000000..31160ff90 --- /dev/null +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -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(ids => + retrieveManyAssets(ctx.db, ctx.tenant.id, ids) + ), +}); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts new file mode 100644 index 000000000..3314e9349 --- /dev/null +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -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 + ), +}); diff --git a/src/core/server/graph/tenant/loaders/index.ts b/src/core/server/graph/tenant/loaders/index.ts new file mode 100644 index 000000000..028d05469 --- /dev/null +++ b/src/core/server/graph/tenant/loaders/index.ts @@ -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), +}); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts new file mode 100644 index 000000000..2946f4ab8 --- /dev/null +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -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(ids => + retrieveMany(ctx.db, ctx.tenant.id, ids) + ), +}); diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts new file mode 100644 index 000000000..edc028608 --- /dev/null +++ b/src/core/server/graph/tenant/middleware.ts @@ -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 }), + }; + }); diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts new file mode 100644 index 000000000..0d2ef65ca --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -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), +}; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts new file mode 100644 index 000000000..d70ea2408 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -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), +}; diff --git a/src/core/server/graph/tenant/resolvers/cursor.ts b/src/core/server/graph/tenant/resolvers/cursor.ts new file mode 100644 index 000000000..9847635dc --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/cursor.ts @@ -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; + } + }, +}); diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts new file mode 100644 index 000000000..a11f73bae --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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, +}; diff --git a/src/core/server/graph/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts similarity index 66% rename from src/core/server/graph/resolvers/query.ts rename to src/core/server/graph/tenant/resolvers/query.ts index 76b81dc50..d62313ff3 100644 --- a/src/core/server/graph/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -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 => { - return ctx.loaders.Asset.asset.load(id); + return ctx.loaders.Assets.asset.load(id); }, }; diff --git a/src/core/server/graph/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts similarity index 83% rename from src/core/server/graph/schema/index.ts rename to src/core/server/graph/tenant/schema/index.ts index 298418491..250991cbe 100644 --- a/src/core/server/graph/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -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(); diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql similarity index 95% rename from src/core/server/graph/schema/schema.graphql rename to src/core/server/graph/tenant/schema/schema.graphql index 24090d016..ef3005475 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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! } ################################################################################ diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts new file mode 100644 index 000000000..514437aad --- /dev/null +++ b/src/core/server/models/actions.ts @@ -0,0 +1,3 @@ +export interface ActionCounts { + [_: string]: number; +} diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index b8abde171..f158ba9a2 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -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 { + return db.collection('assets'); +} + +export interface Asset extends TenantResource { readonly id: string; url: string; scraped?: Date; @@ -24,21 +29,28 @@ export interface Asset { export type CreateAssetInput = Pick; -export async function create(db: Db, input: CreateAssetInput): Promise { +export async function create( + db: Db, + tenantID: string, + input: CreateAssetInput +): Promise { const now = new Date(); // Construct the filter. - const filter: FilterQuery = {}; + const query = new Query(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 { // Perform the upsert operation. const result = await db .collection('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 { return result.value; } -export async function exists(db: Db, id: string): Promise { - // TODO: implement - // const cursor = await db.collection('assets').find({ id }).limit(1); - - return null; -} - -export async function retrieve(db: Db, id: string): Promise { - return await db.collection('assets').findOne({ id }); +export async function retrieve( + db: Db, + tenantID: string, + id: string +): Promise { + return await db + .collection('assets') + .findOne({ id, tenant_id: tenantID }); } export async function retrieveMany( db: Db, + tenantID: string, ids: string[] ): Promise> { const cursor = await db .collection('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, - 'id' | 'url' | 'created_at' + 'id' | 'tenant_id' | 'url' | 'created_at' >; export async function update( db: Db, + tenantID: string, id: string, update: UpdateAssetInput ): Promise> { const result = await db.collection('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 diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 707598328..f49ac6c17 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -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 { + return db.collection('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> { const now = new Date(); @@ -59,6 +72,7 @@ export async function create( // created. const defaults: Sub = { 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('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 { - return null; +export async function retrieve( + db: Db, + tenantID: string, + id: string +): Promise> { + return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieve(db: Db, id: string): Promise { - return null; +export async function retrieveMany( + db: Db, + tenantID: string, + ids: string[] +): Promise[]> { + 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[] { + 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 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>> { + // 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 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>> { + // 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 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 +): Promise>> { + // 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, + }, + }; } diff --git a/src/core/server/models/connection.ts b/src/core/server/models/connection.ts new file mode 100644 index 000000000..701d33351 --- /dev/null +++ b/src/core/server/models/connection.ts @@ -0,0 +1,15 @@ +export type Cursor = Date | number | string; + +export interface Edge { + node: T; + cursor: Cursor; +} + +export interface PageInfo { + hasNextPage: boolean; +} + +export interface Connection { + edges: Edge[]; + pageInfo: PageInfo; +} diff --git a/src/core/server/models/query.ts b/src/core/server/models/query.ts new file mode 100644 index 000000000..969837a48 --- /dev/null +++ b/src/core/server/models/query.ts @@ -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 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 = MongoFilterQuery>>; + +/** + * Query is a convenience class used to wrap the existing MongoDB driver to + * provide easier complex query management. + */ +export default class Query { + public filter: FilterQuery; + + private collection: Collection; + private skip?: number; + private limit?: number; + private sort?: Object; + + constructor(collection: Collection) { + 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): Query { + 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 { + 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 { + 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 { + this.sort = merge({}, this.sort || {}, sort); + return this; + } + + /** + * exec will return a cursor to the query. + */ + async exec(): Promise> { + 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; + } +} diff --git a/src/core/server/models/settings.ts b/src/core/server/models/tenant.ts similarity index 59% rename from src/core/server/models/settings.ts rename to src/core/server/models/tenant.ts index fb4109913..077b272b3 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/tenant.ts @@ -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 { + return db.collection('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; +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 -): Promise> { - const result = await db - .collection('settings') - .findOneAndReplace( - selector, - defaultsDeep({}, settingsInput, defaultSettings), - { - upsert: true, - returnOriginal: false, - } - ); + input: Partial +): Promise> { + 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> { - const settings = await db - .collection('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> { + return collection(db).findOne({ id }); +} - return settings; +export async function retrieveMany( + db: Db, + ids: string[] +): Promise[]> { + 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[]> { + return collection(db) + .find({}) + .toArray(); } export async function update( db: Db, - update: Partial -): Promise> { - // Get the settings from the database. - const result = await db.collection('settings').findOneAndUpdate( - selector, + id: string, + update: Partial +): Promise> { + // 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 diff --git a/src/core/server/models/types.ts b/src/core/server/models/types.ts deleted file mode 100644 index 99820c818..000000000 --- a/src/core/server/models/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FilterQuery } from 'mongodb'; -import { Writeable } from '../../common/types'; - -export type FilterQuery = Writeable> & - FilterQuery>>; diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts new file mode 100644 index 000000000..eb9c7c372 --- /dev/null +++ b/src/core/server/models/user.ts @@ -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 { + return db.collection('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 { + status: T; // TODO: migrate field + assigned_by?: string; + reason?: string; // TODO: migrate field + created_at: Date; +} + +export interface UserStatusItem { + status: T; // TODO: migrate field + history: UserStatusHistory[]; +} + +export interface UserStatus { + username: UserStatusItem; + banned: UserStatusItem; + suspension: UserStatusItem; +} + +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> { + 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 = { + 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> { + return collection(db).findOne({ id, tenant_id: tenantID }); +} + +export async function retrieveMany( + db: Db, + tenantID: string, + ids: string[] +): Promise[]> { + 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> { + const result = await collection(db).findOneAndUpdate( + { id, tenant_id: tenantID }, + { $set: { role } }, + { returnOriginal: false } + ); + + return result.value; +} diff --git a/src/core/server/services/settings/cache.ts b/src/core/server/services/settings/cache.ts deleted file mode 100644 index 5d030836b..000000000 --- a/src/core/server/services/settings/cache.ts +++ /dev/null @@ -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>; - - 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> { - 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> { - // 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; - } -} diff --git a/src/core/server/services/tenant/cache.ts b/src/core/server/services/tenant/cache.ts new file mode 100644 index 000000000..fb3ec68fa --- /dev/null +++ b/src/core/server/services/tenant/cache.ts @@ -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>>; + private tenants: DataLoader>; + 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 => { + // 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> { + 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 { + // 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)); + } +}