[CORL 133] API Review (#2197)

* refactor: removed unused subscription code

* refactor: removed management api's

* refactor: cleanup of connections

* refactor: refactored comments edge

* refactor: simplified connection resolving

* feat: added story connection edge

* fix: added story index

* feat: added user pagination and user edge

* fix: added filter to comment query

* fix: removed unused resolvers

* fix: creating a comment reply should require auth

* refactor: cleanup of graph files

* feat: removed display name, made username non-unique

* fix: fixed tests

* fix: fixed tests

* fix: added more api docs

* fix: fixed bug with installer

* refactor: fixes and updates

* fix: added linting for graphql, fixed schema

* feat: added docker build tests

* fix: upped output timeout

* fix: fixed stacktraces in production builds

* fix: removed `git add`

- `git add` was causing issues with
    partial staged changs on files

* feat: improved error messaging for auth

* refactor: cleaned up queue names

* fix: merge error
This commit is contained in:
Wyatt Johnson
2019-03-12 15:12:21 +01:00
committed by Kiwi
parent 37959f9398
commit d37333be89
125 changed files with 1269 additions and 1536 deletions
@@ -22,7 +22,7 @@ export interface AuthDirectiveArgs {
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username && !user.displayName) {
if (!user.username) {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_NAME);
}
@@ -0,0 +1,2 @@
export * from "./ErrorWrappingExtension";
export * from "./LoggerExtension";
@@ -1,62 +0,0 @@
import { GraphQLOptions } from "apollo-server-express";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
import {
ExpressGraphQLOptionsFunction,
graphqlExpress,
} from "apollo-server-express/dist/expressApollo";
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
import { LoggerExtension } from "./extensions/LoggerExtension";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
Field(node: FieldDefinitionNode) {
if (node.name.value === "__schema" || node.name.value === "__type") {
context.reportError(
new GraphQLError(
"GraphQL introspection is not allowed in production, but the query contained __schema or __type.",
[node]
)
);
}
},
});
export const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction
): Handler => {
// Create a new baseOptions that will be merged into the new options.
const baseOptions: Omit<GraphQLOptions, "schema"> = {
// Disable the debug mode, as we already add in our logging function.
debug: false,
// Include extensions.
extensions: [
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
],
};
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
// Disable introspection in production.
baseOptions.validationRules = [NoIntrospection];
}
// Generate the actual middleware.
return graphqlExpress(async (req, res) => {
// Resolve the options for the GraphQL middleware.
const options = await requestOptions(req, res);
// Provide the options.
return {
...options,
...baseOptions,
};
});
};
@@ -1,29 +0,0 @@
import { execute, GraphQLSchema, subscribe } from "graphql";
import http from "http";
import { SubscriptionServer } from "subscriptions-transport-ws";
export interface SubscriptionMiddlewareOptions {
schema: GraphQLSchema;
path: string;
}
export function handleSubscriptions(
server: http.Server,
{ schema, path }: SubscriptionMiddlewareOptions
): SubscriptionServer {
// Configure some options for the subscription system.
const options = {
schema,
execute,
subscribe,
};
// Configure the socket options for the websocket server. It needs to handle
// upgrade requests on that route.
const socketOption = {
server,
path,
};
return new SubscriptionServer(options, socketOption);
}
@@ -1,15 +0,0 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-server/config";
import { createRedisClient } from "talk-server/services/redis";
export async function createPubSub(config: Config): Promise<RedisPubSub> {
// Create the Redis clients for the PubSub server.
const publisher = await createRedisClient(config);
const subscriber = await createRedisClient(config);
// Create the new PubSub manager.
return new RedisPubSub({
publisher,
subscriber,
});
}
@@ -1,23 +0,0 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
mongo: Db;
config: Config;
i18n: I18n;
req?: Request;
}
export default class ManagementContext extends CommonContext {
public readonly mongo: Db;
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
super({ req, config, i18n });
this.mongo = mongo;
}
}
@@ -1,27 +0,0 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import ManagementContext from "./context";
export interface ManagementGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
i18n: I18n;
}
export default ({
schema,
config,
mongo,
i18n,
}: ManagementGraphQLMiddlewareOptions) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new ManagementContext({ req, mongo, config, i18n }),
}));
@@ -1,9 +0,0 @@
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
const Resolvers: GQLResolver = {
Time,
};
export default Resolvers;
@@ -1,8 +0,0 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers as IResolvers);
}
@@ -1,34 +0,0 @@
################################################################################
## Custom Scalar Types
################################################################################
"""
Time represented as an ISO8601 string.
"""
scalar Time
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
## Query
################################################################################
type Query {
tenant(id: ID!): Tenant
}
-6
View File
@@ -1,6 +0,0 @@
import { GraphQLSchema } from "graphql";
export interface Schemas {
management: GraphQLSchema;
tenant: GraphQLSchema;
}
+20 -32
View File
@@ -1,30 +1,27 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import CommonContext, {
CommonContextOptions,
} from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { TaskQueue } from "talk-server/queue";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
export interface TenantContextOptions extends CommonContextOptions {
mongo: Db;
redis: AugmentedRedis;
tenant: Tenant;
tenantCache: TenantCache;
queue: TaskQueue;
config: Config;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
req?: Request;
user?: User;
i18n: I18n;
}
export default class TenantContext extends CommonContext {
@@ -32,33 +29,24 @@ export default class TenantContext extends CommonContext {
public readonly tenantCache: TenantCache;
public readonly mongo: Db;
public readonly redis: AugmentedRedis;
public readonly queue: TaskQueue;
public readonly mailerQueue: MailerQueue;
public readonly scraperQueue: ScraperQueue;
public readonly loaders: ReturnType<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
public readonly user?: User;
public readonly signingConfig?: JWTSigningConfig;
constructor({
req,
user,
tenant,
mongo,
redis,
config,
tenantCache,
queue,
signingConfig,
i18n,
}: TenantContextOptions) {
super({ user, req, config, i18n, lang: tenant.locale });
constructor(options: TenantContextOptions) {
super({ ...options, lang: options.tenant.locale });
this.tenant = tenant;
this.tenantCache = tenantCache;
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.queue = queue;
this.signingConfig = signingConfig;
this.tenant = options.tenant;
this.tenantCache = options.tenantCache;
this.user = options.user;
this.mongo = options.mongo;
this.redis = options.redis;
this.scraperQueue = options.scraperQueue;
this.mailerQueue = options.mailerQueue;
this.signingConfig = options.signingConfig;
this.loaders = loaders(this);
this.mutators = mutators(this);
}
@@ -1,23 +0,0 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentModerationActionFilter,
retrieveCommentModerationActionConnection,
retrieveCommentModerationActions,
} from "talk-server/models/action/moderation/comment";
import { UserToCommentModerationActionHistoryArgs } from "../schema/__generated__/types";
export default (ctx: TenantContext) => ({
commentModerationActions: (filter: CommentModerationActionFilter) =>
retrieveCommentModerationActions(ctx.mongo, ctx.tenant.id, filter),
commentModerationActionsConnection: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
});
@@ -0,0 +1,31 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentToStatusHistoryArgs,
UserToCommentModerationActionHistoryArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveCommentModerationActionConnection } from "talk-server/models/action/moderation/comment";
export default (ctx: TenantContext) => ({
forModerator: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
forComment: (
{ first = 10, after }: CommentToStatusHistoryArgs,
commentID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
commentID,
},
}),
});
@@ -1,4 +1,5 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import {
@@ -33,9 +34,9 @@ import { SingletonResolver } from "./util";
const primeCommentsFromConnection = (ctx: Context) => (
connection: Readonly<Connection<Readonly<Comment>>>
) => {
// For each of the edges, prime the comment loader.
connection.edges.forEach(({ node }) => {
ctx.loaders.Comments.comment.prime(node.id, node);
// For each of the nodes, prime the comment loader.
connection.nodes.forEach(comment => {
ctx.loaders.Comments.comment.prime(comment.id, comment);
});
return connection;
@@ -45,22 +46,35 @@ export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forFilter: ({ first = 10, after, filter }: QueryToCommentsArgs) =>
forFilter: ({ first = 10, after, storyID, status }: QueryToCommentsArgs) =>
retrieveCommentConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
filter,
filter: omitBy(
{
storyID,
status,
},
isNil
),
}).then(primeCommentsFromConnection(ctx)),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(commentIDs: string[]) =>
retrieveManyUserActionPresence(
(commentIDs: string[]) => {
if (!ctx.user) {
// This should only ever be accessed when a user is logged in. It should
// be safe to get the user here, but we'll throw an error anyways just
// in case.
throw new Error("can't get action presense of an undefined user");
}
return retrieveManyUserActionPresence(
ctx.mongo,
ctx.tenant.id,
// This should only ever be accessed when a user is logged in.
ctx.user!.id,
ctx.user.id,
commentIDs
)
);
}
),
forUser: (
userID: string,
@@ -1,21 +1,62 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLSTORY_STATUS,
GQLStoryMetadata,
QueryToStoriesArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
FindOrCreateStoryInput,
retrieveManyStories,
retrieveStoryConnection,
Story,
StoryConnectionInput,
} from "talk-server/models/story";
import { findOrCreate } from "talk-server/services/stories";
import { scraper } from "talk-server/services/stories/scraper";
const statusFilter = (
status?: GQLSTORY_STATUS
): StoryConnectionInput["filter"] => {
switch (status) {
case GQLSTORY_STATUS.OPEN:
return {
closedAt: null,
};
case GQLSTORY_STATUS.CLOSED:
return {
closedAt: { $lte: new Date() },
};
default:
return {};
}
};
/**
* primeStoriesFromConnection will prime a given context with the stories
* retrieved via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeStoriesFromConnection = (ctx: TenantContext) => (
connection: Readonly<Connection<Readonly<Story>>>
) => {
// For each of these nodes, prime the story loader.
connection.nodes.forEach(story => {
ctx.loaders.Stories.story.prime(story.id, story);
});
return connection;
};
export default (ctx: TenantContext) => ({
findOrCreate: new DataLoader(
(inputs: FindOrCreateStoryInput[]) =>
Promise.all(
inputs.map(input =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.queue.scraper)
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue)
)
),
{
@@ -26,6 +67,15 @@ export default (ctx: TenantContext) => ({
story: new DataLoader<string, Story | null>(ids =>
retrieveManyStories(ctx.mongo, ctx.tenant.id, ids)
),
connection: ({ first = 10, after, status }: QueryToStoriesArgs) =>
retrieveStoryConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
// Merge the status filter into the connection filter.
...statusFilter(status),
},
}).then(primeStoriesFromConnection(ctx)),
debugScrapeMetadata: new DataLoader<string, GQLStoryMetadata | null>(urls =>
Promise.all(urls.map(url => scraper.scrape(url)))
),
+32 -1
View File
@@ -1,6 +1,31 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import { retrieveManyUsers, User } from "talk-server/models/user";
import { QueryToUsersArgs } from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
retrieveManyUsers,
retrieveUserConnection,
User,
} from "talk-server/models/user";
/**
* primeUsersFromConnection will prime a given context with the users retrieved
* via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeUsersFromConnection = (ctx: Context) => (
connection: Readonly<Connection<Readonly<User>>>
) => {
// For each of the nodes, prime the user loader.
connection.nodes.forEach(user => {
ctx.loaders.Users.user.prime(user.id, user);
});
return connection;
};
export default (ctx: Context) => {
const user = new DataLoader<string, User | null>(ids =>
@@ -14,5 +39,11 @@ export default (ctx: Context) => {
return {
user,
connection: ({ first = 10, after, role }: QueryToUsersArgs) =>
retrieveUserConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: omitBy({ role }, isNil),
}).then(primeUsersFromConnection(ctx)),
};
};
@@ -1,14 +1,14 @@
import Context from "talk-server/graph/tenant/context";
import Actions from "./Actions";
import Auth from "./Auth";
import CommentModerationActions from "./CommentModerationActions";
import Comments from "./Comments";
import Stories from "./Stories";
import Users from "./Users";
export default (ctx: Context) => ({
Auth: Auth(ctx),
Actions: Actions(ctx),
CommentModerationActions: CommentModerationActions(ctx),
Stories: Stories(ctx),
Comments: Comments(ctx),
Users: Users(ctx),
@@ -1,36 +0,0 @@
import { GraphQLSchema } from "graphql";
import { graphqlBatchMiddleware } from "talk-server/app/middleware/graphqlBatch";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
export interface TenantGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
}
export default async ({ schema, config }: TenantGraphQLMiddlewareOptions) =>
graphqlBatchMiddleware(
graphqlMiddleware(config, async (req: Request) => {
if (!req.talk) {
throw new Error("talk was not set");
}
const { context } = req.talk;
if (!context) {
throw new Error("context was not set");
}
const { tenant } = context;
if (!tenant) {
throw new Error("tenant was not set");
}
// Return the graph options.
return {
schema,
context: tenant,
};
})
);
@@ -23,7 +23,7 @@ import {
import { validateMaximumLength } from "./util";
export const Comment = (ctx: TenantContext) => ({
export const Comments = (ctx: TenantContext) => ({
create: ({
clientMutationId,
...comment
@@ -10,14 +10,12 @@ import {
GQLScrapeStoryInput,
GQLUpdateStoryInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import * as story from "talk-server/models/story";
import { Story } from "talk-server/models/story";
import { create, merge, remove, update } from "talk-server/services/stories";
import { scrape } from "talk-server/services/stories/scraper";
export const Story = (ctx: TenantContext) => ({
create: async (
input: GQLCreateStoryInput
): Promise<Readonly<story.Story> | null> =>
export const Stories = (ctx: TenantContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
create(
ctx.mongo,
@@ -33,9 +31,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
update: async (
input: GQLUpdateStoryInput
): Promise<Readonly<story.Story> | null> =>
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
{
@@ -45,9 +41,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
ctx.redis,
@@ -55,12 +49,8 @@ export const Story = (ctx: TenantContext) => ({
input.destinationID,
input.sourceIDs
),
remove: async (
input: GQLRemoveStoryInput
): Promise<Readonly<story.Story> | null> =>
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (
input: GQLScrapeStoryInput
): Promise<Readonly<story.Story> | null> =>
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.tenant.id, input.id),
});
@@ -1,7 +1,7 @@
import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import * as user from "talk-server/models/user";
import { User } from "talk-server/models/user";
import {
createToken,
deactivateToken,
@@ -9,13 +9,11 @@ import {
setPassword,
setUsername,
updateAvatar,
updateDisplayName,
updateEmail,
updatePassword,
updateRole,
updateUsername,
} from "talk-server/services/users";
import {
GQLCreateTokenInput,
GQLDeactivateTokenInput,
@@ -24,16 +22,15 @@ import {
GQLSetUsernameInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserDisplayNameInput,
GQLUpdateUserEmailInput,
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
export const User = (ctx: TenantContext) => ({
export const Users = (ctx: TenantContext) => ({
setUsername: async (
input: GQLSetUsernameInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
{
@@ -42,13 +39,10 @@ export const User = (ctx: TenantContext) => ({
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
ERROR_CODES.USERNAME_TOO_SHORT,
ERROR_CODES.DUPLICATE_USERNAME,
],
}
),
setEmail: async (
input: GQLSetEmailInput
): Promise<Readonly<user.User> | null> =>
setEmail: async (input: GQLSetEmailInput): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
{
@@ -62,11 +56,11 @@ export const User = (ctx: TenantContext) => ({
),
setPassword: async (
input: GQLSetPasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
createToken: async (input: GQLCreateTokenInput) =>
createToken(
@@ -81,8 +75,6 @@ export const User = (ctx: TenantContext) => ({
deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id),
updateUserUsername: async (input: GQLUpdateUserUsernameInput) =>
updateUsername(ctx.mongo, ctx.tenant, input.userID, input.username),
updateUserDisplayName: async (input: GQLUpdateUserDisplayNameInput) =>
updateDisplayName(ctx.mongo, ctx.tenant, input.userID, input.displayName),
updateUserEmail: async (input: GQLUpdateUserEmailInput) =>
updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
@@ -1,15 +1,15 @@
import TenantContext from "talk-server/graph/tenant/context";
import { Actions } from "./Actions";
import { Comment } from "./Comment";
import { Comments } from "./Comments";
import { Settings } from "./Settings";
import { Story } from "./Story";
import { User } from "./User";
import { Stories } from "./Stories";
import { Users } from "./Users";
export default (ctx: TenantContext) => ({
Actions: Actions(ctx),
Comment: Comment(ctx),
Comments: Comments(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
User: User(ctx),
Stories: Stories(ctx),
Users: Users(ctx),
});
@@ -9,8 +9,8 @@ import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/helpers/connection";
import { getCommentEditableUntilDate } from "talk-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
@@ -55,13 +55,14 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
}),
author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID),
statusHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActions({
commentID: id,
}),
ctx.loaders.CommentModerationActions.forComment(input, id),
replies: (c, input, ctx) =>
// If there is at least one reply, then use the connection loader, otherwise
// return a blank connection.
c.replyCount > 0
? ctx.loaders.Comments.forParent(c.storyID, c.id, input)
: createConnection(),
// Action Counts are encoded, decode them for use with the GraphQL system.
actionCounts: c => decodeActionCounts(c.actionCounts),
myActionPresence: (c, input, ctx) =>
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
@@ -1,4 +1,5 @@
import * as actions from "talk-server/models/action/moderation/comment";
import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types";
export const CommentModerationAction: GQLCommentModerationActionTypeResolver<
@@ -1,10 +1,10 @@
import { GQLCommentRevisionTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { Comment, Revision } from "talk-server/models/comment";
export interface WrappedCommentRevision {
revision: comment.Revision;
comment: comment.Comment;
revision: Revision;
comment: Comment;
}
export const CommentRevision: Required<
@@ -8,8 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
GQLFacebookAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver(
"/api/tenant/auth/facebook/callback"
),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/facebook"),
callbackURL: reconstructTenantURLResolver("/api/auth/facebook/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/facebook"),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GQLGoogleAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/google"),
callbackURL: reconstructTenantURLResolver("/api/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/google"),
};
@@ -2,26 +2,24 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__gener
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
comment: await ctx.mutators.Comments.edit(input),
clientMutationId: input.clientMutationId,
}),
createComment: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
createCommentReply: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
@@ -30,23 +28,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createReaction(input),
comment: await ctx.mutators.Comments.createReaction(input),
clientMutationId: input.clientMutationId,
}),
removeCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeReaction(input),
comment: await ctx.mutators.Comments.removeReaction(input),
clientMutationId: input.clientMutationId,
}),
createCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createDontAgree(input),
comment: await ctx.mutators.Comments.createDontAgree(input),
clientMutationId: input.clientMutationId,
}),
removeCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeDontAgree(input),
comment: await ctx.mutators.Comments.removeDontAgree(input),
clientMutationId: input.clientMutationId,
}),
createCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createFlag(input),
comment: await ctx.mutators.Comments.createFlag(input),
clientMutationId: input.clientMutationId,
}),
regenerateSSOKey: async (source, { input }, ctx) => ({
@@ -54,23 +52,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.create(input),
story: await ctx.mutators.Stories.create(input),
clientMutationId: input.clientMutationId,
}),
updateStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.update(input),
story: await ctx.mutators.Stories.update(input),
clientMutationId: input.clientMutationId,
}),
mergeStories: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.merge(input),
story: await ctx.mutators.Stories.merge(input),
clientMutationId: input.clientMutationId,
}),
removeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.remove(input),
story: await ctx.mutators.Stories.remove(input),
clientMutationId: input.clientMutationId,
}),
scrapeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.scrape(input),
story: await ctx.mutators.Stories.scrape(input),
clientMutationId: input.clientMutationId,
}),
acceptComment: async (source, { input }, ctx) => ({
@@ -82,47 +80,43 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
setUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setUsername(input),
user: await ctx.mutators.Users.setUsername(input),
clientMutationId: input.clientMutationId,
}),
setEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setEmail(input),
user: await ctx.mutators.Users.setEmail(input),
clientMutationId: input.clientMutationId,
}),
setPassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setPassword(input),
user: await ctx.mutators.Users.setPassword(input),
clientMutationId: input.clientMutationId,
}),
updatePassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updatePassword(input),
user: await ctx.mutators.Users.updatePassword(input),
clientMutationId: input.clientMutationId,
}),
createToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.createToken(input)),
...(await ctx.mutators.Users.createToken(input)),
clientMutationId: input.clientMutationId,
}),
deactivateToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.deactivateToken(input)),
...(await ctx.mutators.Users.deactivateToken(input)),
clientMutationId: input.clientMutationId,
}),
updateUserUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserDisplayName: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserDisplayName(input),
user: await ctx.mutators.Users.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserEmail(input),
user: await ctx.mutators.Users.updateUserEmail(input),
clientMutationId: input.clientMutationId,
}),
updateUserAvatar: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserAvatar(input),
user: await ctx.mutators.Users.updateUserAvatar(input),
clientMutationId: input.clientMutationId,
}),
updateUserRole: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserRole(input),
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
GQLOIDCAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/oidc"),
callbackURL: reconstructTenantURLResolver("/api/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/oidc"),
};
@@ -1,5 +1,4 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<user.Profile> = profile => {
@@ -2,8 +2,11 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate
import { sharedModerationInputResolver } from "./ModerationQueues";
export const Query: GQLQueryTypeResolver<void> = {
export const Query: Required<GQLQueryTypeResolver<void>> = {
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args),
stories: (source, args, ctx) => ctx.loaders.Stories.connection(args),
user: (source, args, ctx) => ctx.loaders.Users.user.load(args.id),
users: (source, args, ctx) => ctx.loaders.Users.connection(args),
comment: (source, { id }, ctx) =>
id ? ctx.loaders.Comments.comment.load(id) : null,
comments: (source, args, ctx) => ctx.loaders.Comments.forFilter(args),
@@ -4,5 +4,5 @@ import * as user from "talk-server/models/user";
export const User: GQLUserTypeResolver<user.User> = {
comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input),
commentModerationActionHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActionsConnection(input, id),
ctx.loaders.CommentModerationActions.forModerator(input, id),
};
@@ -1,6 +1,5 @@
import Cursor from "talk-server/graph/common/scalars/cursor";
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AcceptCommentPayload } from "./AcceptCommentPayload";
@@ -29,17 +28,17 @@ const Resolvers: GQLResolver = {
CommentModerationAction,
CommentRevision,
Cursor,
Mutation,
ModerationQueue,
ModerationQueues,
OIDCAuthIntegration,
FacebookAuthIntegration,
GoogleAuthIntegration,
ModerationQueue,
ModerationQueues,
Mutation,
OIDCAuthIntegration,
Profile,
Query,
RejectCommentPayload,
Time,
Story,
Time,
User,
};
@@ -1,9 +1,9 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import TenantContext from "../context";
+180 -106
View File
@@ -602,22 +602,30 @@ type FacebookAuthIntegration {
##########################
type AuthIntegrations {
"""
local stores configuration related to email/password based logins.
"""
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
"""
AuthDisplayNameConfiguration allows configuration related to Display Names.
"""
type AuthDisplayNameConfiguration {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SSOAuthIntegration!
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: OIDCAuthIntegration!
"""
google stores configuration related to Google based logins.
"""
google: GoogleAuthIntegration!
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: FacebookAuthIntegration!
}
"""
@@ -630,12 +638,6 @@ type Auth {
authentication solutions.
"""
integrations: AuthIntegrations!
"""
displayName contains configuration related to the use of Display Names across
AuthIntegrations.
"""
displayName: AuthDisplayNameConfiguration! @auth(roles: [ADMIN])
}
################################################################################
@@ -1068,11 +1070,6 @@ type User {
"""
username: String
"""
displayName is provided optionally when enabled and available.
"""
displayName: String
"""
email is the current email address for the User.
"""
@@ -1114,12 +1111,52 @@ type User {
commentModerationActionHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN])
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]! @auth(role: [ADMIN], userIDField: "id")
tokens: [Token!]! @auth(roles: [ADMIN], userIDField: "id")
"""
createdAt is the time that the User was created at.
"""
createdAt: Time! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
}
"""
UserEdge represents a unique User in a UsersConnection.
"""
type UserEdge {
"""
node is the User for this edge.
"""
node: User!
"""
cursor is used in pagination.
"""
cursor: Cursor!
}
"""
UsersConnection represents a subset of a stories list.
"""
type UsersConnection {
"""
edges are a subset of UserEdge's.
"""
edges: [UserEdge!]!
"""
nodes is a list of User's.
"""
nodes: [User!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1201,9 +1238,9 @@ type CommentModerationActionEdge {
node: CommentModerationAction!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
type CommentModerationActionConnection {
@@ -1213,7 +1250,12 @@ type CommentModerationActionConnection {
edges: [CommentModerationActionEdge!]!
"""
pageInfo is
nodes is a list of CommentModerationAction's.
"""
nodes: [CommentModerationAction!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1295,7 +1337,10 @@ type Comment {
the history of moderator actions performed on the Comment, with the most
recent last.
"""
statusHistory: [CommentModerationAction!]! @auth(role: [MODERATOR, ADMIN])
statusHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
parentCount is the number of direct parents for this Comment. Currently this
@@ -1399,9 +1444,9 @@ type CommentEdge {
node: Comment!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
"""
@@ -1414,7 +1459,12 @@ type CommentsConnection {
edges: [CommentEdge!]!
"""
pageInfo is
nodes is a list of Comment's.
"""
nodes: [Comment!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1517,6 +1567,21 @@ type StoryMetadata {
section: String
}
"""
STORY_STATUS represents filtering states that a Story can be in.
"""
enum STORY_STATUS {
"""
OPEN represents when a given Story is open for commenting.
"""
OPEN
"""
CLOSED represents when a given Story is not open for commenting.
"""
CLOSED
}
"""
Story is an Article or Page where Comments are written on by Users.
"""
@@ -1537,7 +1602,8 @@ type Story {
metadata: StoryMetadata
"""
scrapedAt is the Time that the Story had it's metadata scraped at.
scrapedAt is the Time that the Story had it's metadata scraped at. If the time
is null, the Story has not been scraped yet.
"""
scrapedAt: Time
@@ -1563,7 +1629,8 @@ type Story {
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
closedAt is the Time that the Story is closed for commenting. If null or in
the future, the story is not yet closed.
"""
closedAt: Time
@@ -1588,21 +1655,39 @@ type Story {
moderation: MODERATION_MODE!
}
################################################################################
## CommentsFilterInput
################################################################################
input CommentsFilterInput {
"""
StoryEdge represents a unique Story in a StoriesConnection.
"""
type StoryEdge {
"""
storyID when specified, will filter to show only Comment's on that Story.
node is the Story for this edge.
"""
storyID: ID
node: Story!
"""
status when specified, will filter to show only Comment's with that
COMMENT_STATUS.
cursor is used in pagination.
"""
status: COMMENT_STATUS
cursor: Cursor!
}
"""
StoriesConnection represents a subset of a stories list.
"""
type StoriesConnection {
"""
edges are a subset of StoryEdge's.
"""
edges: [StoryEdge!]!
"""
nodes is a list of Story's.
"""
nodes: [Story!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1616,22 +1701,46 @@ type Query {
comment(id: ID!): Comment
"""
comments returns a filtered comments connection that can be paginated.
comments returns a filtered comments connection that can be paginated. This is
a fairly expensive edge to filter against, moderation queues should utlilize
the dedicated edges for more optimized responses.
"""
comments(
first: Int = 10
after: Cursor
filter: CommentsFilterInput!
storyID: ID
status: COMMENT_STATUS
): CommentsConnection @auth(roles: [ADMIN, MODERATOR])
"""
story is the Story specified by its ID/URL.
story is a specific article that can be identified by either an ID or a URL.
"""
story(id: ID, url: String): Story
"""
stories returns filtered stories that can be paginated.
"""
stories(
first: Int = 10
after: Cursor
status: STORY_STATUS
): StoriesConnection @auth(roles: [ADMIN, MODERATOR])
"""
user will return the user referenced by their ID.
"""
user(id: ID!): User @auth(roles: [ADMIN, MODERATOR])
"""
users returns filtered users that can be paginated.
"""
users(first: Int = 10, after: Cursor, role: USER_ROLE): UsersConnection!
@auth(roles: [ADMIN, MODERATOR])
"""
me is the current logged in User. If no user is currently logged in, it will
return null.
return null. This is the only nullable field that can be returned that depends
on the authentication state that will not throw an error.
"""
me: User
@@ -1982,19 +2091,30 @@ input SettingsFacebookAuthIntegrationInput {
}
input SettingsAuthIntegrationsInput {
"""
local stores configuration related to email/password based logins.
"""
local: SettingsLocalAuthIntegrationInput
sso: SettingsSSOAuthIntegrationInput
oidc: SettingsOIDCAuthIntegrationInput
google: SettingsGoogleAuthIntegrationInput
facebook: SettingsFacebookAuthIntegrationInput
}
input SettingsAuthDisplayNameInput {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SettingsSSOAuthIntegrationInput
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: SettingsOIDCAuthIntegrationInput
"""
google stores configuration related to Google based logins.
"""
google: SettingsGoogleAuthIntegrationInput
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: SettingsFacebookAuthIntegrationInput
}
"""
@@ -2002,12 +2122,6 @@ Auth contains all the settings related to authentication and
authorization.
"""
input SettingsAuthInput {
"""
"
displayName allows configuration related to Display Names.
"""
displayName: SettingsAuthDisplayNameInput
"""
integrations are the set of configurations for the variations of
authentication solutions.
@@ -3047,40 +3161,6 @@ type UpdateUserUsernamePayload {
clientMutationId: String!
}
##################
# updateUserDisplayName
##################
input UpdateUserDisplayNameInput {
"""
userID is the ID of the User that should have their display name updated.
"""
userID: ID!
"""
displayName is the desired display name to set for the User. If set to `null`
or not provided, it will be unset.
"""
displayName: String
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateUserDisplayNamePayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updateUserEmail
##################
@@ -3195,7 +3275,9 @@ type Mutation {
createCommentReply will create a Comment as the current logged in User that is
in reply to another Comment.
"""
createCommentReply(input: CreateCommentReplyInput!): CreateCommentReplyPayload
createCommentReply(
input: CreateCommentReplyInput!
): CreateCommentReplyPayload @auth
"""
editComment will allow the author of a comment to change the body within the
@@ -3346,14 +3428,6 @@ type Mutation {
input: UpdateUserUsernameInput!
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
"""
updateUserDisplayName allows administrators to update a given User's display
name to the one provided.
"""
updateUserDisplayName(
input: UpdateUserDisplayNameInput!
): UpdateUserDisplayNamePayload @auth(roles: [ADMIN])
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.