[next] Moderate (#2118)

* fix: load .env before building / watching

* feat: Implement AppBar, Brand, and SubBar

* feat: add card ui component

* feat: add modqueue components

* feat: implement modqueue

* feat: add translations

* test: add unit tests

* feat: single comment view

* test: feature / integration tests for modqueue

* test: fix remaining tests

* feature: support TextMatchOptions

* fix: remove body count marker

* fix: remove accidently added package

* feat: testHelper toJSON

* chore: cleanup + comments

* chore: better types

* test: fix test

* chore: refactor decision history test

* chore: tiny fix

* fix: adjust to recent server changes

* fix: marking suspect and banned words

* feat: added moderation queue edge to accept/reject comment payloads

- Simplified moderationQueue returns
- Simplified resolvers

* feat: update counts

* feat: added id's to moderation queue and settings

* fix+test: test count changes, apply fix

* chore: adapt to server change, and remove custom mutation handlers

* fix: use common utils

* fix: purify fix, babel fix

* fix: workaround css treeshake issue and upgrade css plugins

* fix: fixed snapshot

* fix: support empty word lists

* feat: separate client config
This commit is contained in:
Kiwi
2018-12-18 18:00:39 +00:00
committed by Wyatt Johnson
parent 90e67ca479
commit 1fc49f8e50
316 changed files with 11587 additions and 3005 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import uuid from "uuid";
import { Config } from "talk-common/config";
import { Config } from "talk-server/config";
import logger from "talk-server/logger";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
@@ -8,8 +8,8 @@ import {
graphqlExpress,
} from "apollo-server-express/dist/expressApollo";
import { Config } from "talk-common/config";
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import { LoggerExtension } from "talk-server/graph/common/middleware/extensions/logger";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
@@ -1,5 +1,5 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-common/config";
import { Config } from "talk-server/config";
import { createRedisClient } from "talk-server/services/redis";
export function createPubSub(config: Config): RedisPubSub {
+1 -1
View File
@@ -1,6 +1,6 @@
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { Request } from "talk-server/types/express";
@@ -1,7 +1,7 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
+1 -1
View File
@@ -1,8 +1,8 @@
import { GraphQLSchema } from "graphql";
// import { graphqlBatchHTTPWrapper } from "react-relay-network-layer";
import { Config } from "talk-common/config";
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";
@@ -0,0 +1,7 @@
import { GQLAcceptCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { moderationQueuesPayloadResolver } from "./ModerationQueues";
export const AcceptCommentPayload: GQLAcceptCommentPayloadTypeResolver = {
moderationQueues: moderationQueuesPayloadResolver,
};
@@ -10,6 +10,7 @@ import { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/connection";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
const maybeLoadOnlyID = (
ctx: TenantContext,
@@ -77,4 +78,12 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
// Some resolver optimization.
c.parentID ? ctx.loaders.Comments.parents(c, input) : createConnection(),
story: (c, input, ctx) => ctx.loaders.Stories.story.load(c.storyID),
permalink: async (c, input, ctx) => {
const story = await ctx.loaders.Stories.story.load(c.storyID);
if (!story) {
// TODO: better error reporting?
throw new Error("Story not found");
}
return getURLWithCommentID(story.url, c.id);
},
};
@@ -8,6 +8,7 @@ import {
} from "../schema/__generated__/types";
export interface ModerationQueueInput {
selector: string;
connection: Partial<CommentConnectionInput>;
count: number;
}
@@ -15,6 +16,14 @@ export interface ModerationQueueInput {
export const ModerationQueue: GQLModerationQueueTypeResolver<
ModerationQueueInput
> = {
id: ({ selector, connection: { filter } }) => {
// NOTE: (wyattjoh) when the queues change shape in the future, investigate adding more dynamicness to this id generation
if (filter && filter.storyID) {
return selector + "::storyID:" + filter.storyID;
}
return selector;
},
comments: ({ connection }, { first = 10, after }, { mongo, tenant }) =>
retrieveCommentConnection(mongo, tenant.id, {
...connection,
@@ -1,16 +1,24 @@
import {
AcceptCommentPayloadToModerationQueuesResolver,
GQLModerationQueuesTypeResolver,
RejectCommentPayloadToModerationQueuesResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { CommentConnectionInput } from "talk-server/models/comment";
import { FilterQuery } from "talk-server/models/query";
import { CommentModerationCountsPerQueue } from "talk-server/models/story";
import {
CommentModerationCountsPerQueue,
Story,
} from "talk-server/models/story";
import {
PENDING_STATUS,
REPORTED_STATUS,
UNMODERATED_STATUSES,
} from "talk-server/services/comments/moderation/counts";
import { GQLModerationQueuesTypeResolver } from "../schema/__generated__/types";
import TenantContext from "../context";
import { ModerationQueueInput } from "./ModerationQueue";
export interface ModerationQueuesInput {
interface ModerationQueuesInput {
connection: Partial<CommentConnectionInput>;
counts: CommentModerationCountsPerQueue;
}
@@ -19,6 +27,7 @@ const mergeModerationInputFilters = (
filter: FilterQuery<Comment>,
selector: keyof CommentModerationCountsPerQueue
) => (input: ModerationQueuesInput): ModerationQueueInput => ({
selector,
connection: {
...input.connection,
filter: {
@@ -29,6 +38,72 @@ const mergeModerationInputFilters = (
count: input.counts[selector],
});
/**
* storyModerationInputResolver can be used to retrieve the moderationQueue for
* a specific Story.
*
* @param story the story that will be used to base the comment moderation
* queues on
*/
export const storyModerationInputResolver = (
story: Story
): ModerationQueuesInput => ({
connection: {
filter: {
// This moderationQueues is being sourced from the Story, so require
// that all the comments for theses queues are also for this Story.
storyID: story.id,
},
},
counts: story.commentCounts.moderationQueue.queues,
});
/**
* sharedModerationInputResolver implements the resolver function style which
* allows it to be used in a type resolver.
*
* @param source the source of the type, not used
* @param args the args of the type, not used
* @param ctx the TenantContext that will be used to get the shared counts
*/
export const sharedModerationInputResolver = async (
source: any,
args: any,
ctx: TenantContext
): Promise<ModerationQueuesInput> => ({
// We don't need to filter the connection, as this is tenant wide (tenant
// filtering is completed at the model layer).
connection: {},
counts: await ctx.loaders.Comments.sharedModerationQueueQueuesCounts.load(),
});
/**
* moderationQueuesPayloadResolver implements the resolver that can be used for
* moderation actions payloads.
*
* @param source the source of the payload, not used
* @param args the args of the payload containing potentially a Story ID
* @param ctx the TenantContext for which we can use to retrieve the shared data
*/
export const moderationQueuesPayloadResolver:
| AcceptCommentPayloadToModerationQueuesResolver
| RejectCommentPayloadToModerationQueuesResolver = async (
source,
args,
ctx
): Promise<ModerationQueuesInput | null> => {
if (args.storyID) {
const story = await ctx.loaders.Stories.story.load(args.storyID);
if (!story) {
return null;
}
return storyModerationInputResolver(story);
}
return sharedModerationInputResolver(source, args, ctx);
};
export const ModerationQueues: GQLModerationQueuesTypeResolver<
ModerationQueuesInput
> = {
@@ -1,6 +1,6 @@
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationQueuesInput } from "./ModerationQueues";
import { sharedModerationInputResolver } from "./ModerationQueues";
export const Query: GQLQueryTypeResolver<void> = {
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args),
@@ -13,14 +13,5 @@ export const Query: GQLQueryTypeResolver<void> = {
ctx.loaders.Auth.discoverOIDCConfiguration.load(issuer),
debugScrapeStoryMetadata: (source, { url }, ctx) =>
ctx.loaders.Stories.debugScrapeMetadata.load(url),
moderationQueues: async (
source,
args,
ctx
): Promise<ModerationQueuesInput> => ({
// We don't need to filter the connection, as this is tenant wide (tenant
// filtering is completed at the model layer).
connection: {},
counts: await ctx.loaders.Comments.sharedModerationQueueQueuesCounts.load(),
}),
moderationQueues: sharedModerationInputResolver,
};
@@ -0,0 +1,7 @@
import { GQLRejectCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { moderationQueuesPayloadResolver } from "./ModerationQueues";
export const RejectCommentPayload: GQLRejectCommentPayloadTypeResolver = {
moderationQueues: moderationQueuesPayloadResolver,
};
@@ -3,7 +3,8 @@ import { DateTime } from "luxon";
import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as story from "talk-server/models/story";
import { ModerationQueuesInput } from "./ModerationQueues";
import { storyModerationInputResolver } from "./ModerationQueues";
export const Story: GQLStoryTypeResolver<story.Story> = {
comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input),
@@ -23,14 +24,5 @@ export const Story: GQLStoryTypeResolver<story.Story> = {
},
commentActionCounts: s => decodeActionCounts(s.commentCounts.action),
commentCounts: s => s.commentCounts.status,
moderationQueues: (s): ModerationQueuesInput => ({
connection: {
filter: {
// This moderationQueues is being sourced from the Story, so require
// that all the comments for theses queues are also for this Story.
storyID: s.id,
},
},
counts: s.commentCounts.moderationQueue.queues,
}),
moderationQueues: storyModerationInputResolver,
};
@@ -3,6 +3,7 @@ import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AcceptCommentPayload } from "./AcceptCommentPayload";
import { AuthIntegrations } from "./AuthIntegrations";
import { Comment } from "./Comment";
import { CommentCounts } from "./CommentCounts";
@@ -16,10 +17,12 @@ import { Mutation } from "./Mutation";
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
import { Profile } from "./Profile";
import { Query } from "./Query";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Story } from "./Story";
import { User } from "./User";
const Resolvers: GQLResolver = {
AcceptCommentPayload,
AuthIntegrations,
Comment,
CommentCounts,
@@ -34,6 +37,7 @@ const Resolvers: GQLResolver = {
GoogleAuthIntegration,
Profile,
Query,
RejectCommentPayload,
Time,
Story,
User,
@@ -1,6 +1,8 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { URL } from "url";
/**
* getRequestedFields returns the fields in an array that are being queried for.
@@ -10,3 +12,17 @@ import { pull } from "lodash";
export function getRequestedFields<T>(info: GraphQLResolveInfo) {
return pull(Object.keys(graphqlFields<T>(info)), "__typename");
}
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
@@ -248,6 +248,11 @@ type WordList {
ModerationQueue returns the Comments associated with a Moderation Queue.
"""
type ModerationQueue {
"""
id is a canonical identifier for this specific moderation queue.
"""
id: ID!
"""
count will return the number of Comments that are in this queue.
"""
@@ -782,6 +787,11 @@ type ReactionConfiguration {
Settings stores the global settings for a given Tenant.
"""
type Settings {
"""
id is a canonical identifier for this Tenant.
"""
id: ID!
"""
domain is the domain that is associated with this Tenant.
"""
@@ -1268,6 +1278,11 @@ type Comment {
story is the Story that the Comment was written on.
"""
story: Story!
"""
The permalink for this comment.
"""
permalink: String!
}
type PageInfo {
@@ -1463,7 +1478,7 @@ type Story {
moderationQueues returns the set of ModerationQueues that are available for
this Story.
"""
moderationQueues: ModerationQueues @auth(roles: [ADMIN, MODERATOR])
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
@@ -1591,7 +1606,7 @@ type Query {
moderationQueues returns the set of ModerationQueues that are available for
all stories.
"""
moderationQueues: ModerationQueues @auth(roles: [ADMIN, MODERATOR])
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
}
################################################################################
@@ -2867,6 +2882,13 @@ type AcceptCommentPayload {
"""
comment: Comment
"""
moderationQueues will return the selected moderation queues. If the `storyID`
is provided, it will filter the moderation queues for only comments in that
Story.
"""
moderationQueues(storyID: ID): ModerationQueues
"""
clientMutationId is required for Relay support.
"""
@@ -2900,6 +2922,13 @@ type RejectCommentPayload {
"""
comment: Comment
"""
moderationQueues will return the selected moderation queues. If the `storyID`
is provided, it will filter the moderation queues for only comments in that
Story.
"""
moderationQueues(storyID: ID): ModerationQueues
"""
clientMutationId is required for Relay support.
"""