[next] Moderation Queues (#2098)

* fix: edit comment fix for reactions

* feat: Comment Queue Counts

- Removed "remove flag"
- Rearranged moderation services
- Rearranged comment counts on stories
- Added moderation queue counts to stories
- Added comments edge
- Improved Cursor/Connection types
- Improved count updators for stories

* feat: added shared comment counts and queues

- Added new AugmentedRedis type
- Added more log calls
- Update counts in shared counter as well
- Return a Query level Moderation Queue

* fix: fixed test
This commit is contained in:
Wyatt Johnson
2018-12-07 00:37:33 +01:00
committed by Kiwi
parent 5b62082693
commit 4584c3d4fb
53 changed files with 2444 additions and 768 deletions
+4 -4
View File
@@ -1,20 +1,20 @@
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import CommonContext 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 { AugmentedRedis } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import { Config } from "talk-common/config";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
mongo: Db;
redis: Redis;
redis: AugmentedRedis;
tenant: Tenant;
tenantCache: TenantCache;
queue: TaskQueue;
@@ -28,7 +28,7 @@ export default class TenantContext extends CommonContext {
public readonly tenantCache: TenantCache;
public readonly user?: User;
public readonly mongo: Db;
public readonly redis: Redis;
public readonly redis: AugmentedRedis;
public readonly queue: TaskQueue;
public readonly loaders: ReturnType<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
@@ -6,11 +6,13 @@ import {
CommentToRepliesArgs,
GQLActionPresence,
GQLCOMMENT_SORT,
QueryToCommentsArgs,
StoryToCommentsArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveManyUserActionPresence } from "talk-server/models/action/comment";
import {
Comment,
retrieveCommentConnection,
retrieveCommentParentsConnection,
retrieveCommentRepliesConnection,
retrieveCommentStoryConnection,
@@ -40,6 +42,13 @@ export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forFilter: ({ first = 10, after, filter }: QueryToCommentsArgs) =>
retrieveCommentConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
filter,
}).then(primeCommentsFromConnection(ctx)),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(commentIDs: string[]) =>
retrieveManyUserActionPresence(
@@ -63,7 +72,7 @@ export default (ctx: Context) => ({
first,
orderBy,
after,
}),
}).then(primeCommentsFromConnection(ctx)),
forStory: (
storyID: string,
// Apply the graph schema defaults at the loader.
@@ -1,5 +1,5 @@
import TenantContext from "talk-server/graph/tenant/context";
import { accept, reject } from "talk-server/services/moderation";
import { accept, reject } from "talk-server/services/comments/moderation";
import {
GQLAcceptCommentInput,
GQLRejectCommentInput,
@@ -7,13 +7,13 @@ import {
export const Actions = (ctx: TenantContext) => ({
acceptComment: (input: GQLAcceptCommentInput) =>
accept(ctx.mongo, ctx.tenant, {
accept(ctx.mongo, ctx.redis, ctx.tenant, {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
moderatorID: ctx.user!.id,
}),
rejectComment: (input: GQLRejectCommentInput) =>
reject(ctx.mongo, ctx.tenant, {
reject(ctx.mongo, ctx.redis, ctx.tenant, {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
moderatorID: ctx.user!.id,
@@ -7,7 +7,6 @@ import {
GQLCreateCommentReplyInput,
GQLEditCommentInput,
GQLRemoveCommentDontAgreeInput,
GQLRemoveCommentFlagInput,
GQLRemoveCommentReactionInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { create, edit } from "talk-server/services/comments";
@@ -16,7 +15,6 @@ import {
createFlag,
createReaction,
removeDontAgree,
removeFlag,
removeReaction,
} from "talk-server/services/comments/actions";
@@ -27,6 +25,7 @@ export const Comment = (ctx: TenantContext) => ({
}: GQLCreateCommentInput | GQLCreateCommentReplyInput) =>
create(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
@@ -35,6 +34,7 @@ export const Comment = (ctx: TenantContext) => ({
edit: ({ commentID, body }: GQLEditCommentInput) =>
edit(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
@@ -47,24 +47,24 @@ export const Comment = (ctx: TenantContext) => ({
commentID,
commentRevisionID,
}: GQLCreateCommentReactionInput) =>
createReaction(ctx.mongo, ctx.tenant, ctx.user!, {
createReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
}),
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.tenant, ctx.user!, {
removeReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
}),
createDontAgree: ({
commentID,
commentRevisionID,
}: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
createDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
}),
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
removeDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
}),
createFlag: ({
@@ -72,13 +72,9 @@ export const Comment = (ctx: TenantContext) => ({
commentRevisionID,
reason,
}: GQLCreateCommentFlagInput) =>
createFlag(ctx.mongo, ctx.tenant, ctx.user!, {
createFlag(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
reason,
}),
removeFlag: ({ commentID }: GQLRemoveCommentFlagInput) =>
removeFlag(ctx.mongo, ctx.tenant, ctx.user!, {
commentID,
}),
});
@@ -30,7 +30,13 @@ export const Story = (ctx: TenantContext) => ({
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs),
merge(
ctx.mongo,
ctx.redis,
ctx.tenant,
input.destinationID,
input.sourceIDs
),
remove: async (
input: GQLRemoveStoryInput
): Promise<Readonly<story.Story> | null> =>
@@ -0,0 +1,25 @@
import {
CommentConnectionInput,
retrieveCommentConnection,
} from "talk-server/models/comment";
import {
GQLCOMMENT_SORT,
GQLModerationQueueTypeResolver,
} from "../schema/__generated__/types";
export interface ModerationQueueInput {
connection: Partial<CommentConnectionInput>;
count: number;
}
export const ModerationQueue: GQLModerationQueueTypeResolver<
ModerationQueueInput
> = {
comments: ({ connection }, { first = 10, after }, { mongo, tenant }) =>
retrieveCommentConnection(mongo, tenant.id, {
...connection,
first,
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
}),
};
@@ -0,0 +1,54 @@
import { CommentConnectionInput } from "talk-server/models/comment";
import { FilterQuery } from "talk-server/models/query";
import { CommentModerationCountsPerQueue } 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 { ModerationQueueInput } from "./ModerationQueue";
export interface ModerationQueuesInput {
connection: Partial<CommentConnectionInput>;
counts: CommentModerationCountsPerQueue;
}
const mergeModerationInputFilters = (
filter: FilterQuery<Comment>,
selector: keyof CommentModerationCountsPerQueue
) => (input: ModerationQueuesInput): ModerationQueueInput => ({
connection: {
...input.connection,
filter: {
...input.connection.filter,
...filter,
},
},
count: input.counts[selector],
});
export const ModerationQueues: GQLModerationQueuesTypeResolver<
ModerationQueuesInput
> = {
unmoderated: mergeModerationInputFilters(
{
status: { $in: UNMODERATED_STATUSES },
},
"unmoderated"
),
reported: mergeModerationInputFilters(
{
status: { $in: REPORTED_STATUS },
"actionCounts.FLAG": { $gt: 0 },
},
"reported"
),
pending: mergeModerationInputFilters(
{
status: { $in: PENDING_STATUS },
},
"pending"
),
};
@@ -49,10 +49,6 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
comment: await ctx.mutators.Comment.createFlag(input),
clientMutationId: input.clientMutationId,
}),
removeCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeFlag(input),
clientMutationId: input.clientMutationId,
}),
regenerateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.regenerateSSOKey(),
clientMutationId: input.clientMutationId,
@@ -1,13 +1,30 @@
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveSharedModerationQueueQueuesCounts } from "talk-server/models/story";
import { ModerationQueuesInput } from "./ModerationQueues";
export const Query: GQLQueryTypeResolver<void> = {
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate(args),
comment: (source, { id }, ctx) =>
id ? ctx.loaders.Comments.comment.load(id) : null,
comments: (source, args, ctx) => ctx.loaders.Comments.forFilter(args),
settings: (source, args, ctx) => ctx.tenant,
me: (source, args, ctx) => ctx.user,
discoverOIDCConfiguration: (source, { issuer }, ctx) =>
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 retrieveSharedModerationQueueQueuesCounts(
ctx.mongo,
ctx.redis,
ctx.tenant.id
),
}),
};
@@ -3,6 +3,7 @@ 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";
export const Story: GQLStoryTypeResolver<story.Story> = {
comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input),
@@ -20,6 +21,16 @@ export const Story: GQLStoryTypeResolver<story.Story> = {
return null;
},
commentActionCounts: s => decodeActionCounts(s.commentActionCounts),
commentCounts: s => s.commentCounts,
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,
}),
};
@@ -10,6 +10,8 @@ import { CommentModerationAction } from "./CommentModerationAction";
import { CommentRevision } from "./CommentRevision";
import { FacebookAuthIntegration } from "./FacebookAuthIntegration";
import { GoogleAuthIntegration } from "./GoogleAuthIntegration";
import { ModerationQueue } from "./ModerationQueue";
import { ModerationQueues } from "./ModerationQueues";
import { Mutation } from "./Mutation";
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
import { Profile } from "./Profile";
@@ -25,6 +27,8 @@ const Resolvers: GQLResolver = {
CommentRevision,
Cursor,
Mutation,
ModerationQueue,
ModerationQueues,
OIDCAuthIntegration,
FacebookAuthIntegration,
GoogleAuthIntegration,
@@ -240,6 +240,50 @@ type WordList {
suspect: [String!]!
}
################################################################################
## Moderation
################################################################################
"""
ModerationQueue returns the Comments associated with a Moderation Queue.
"""
type ModerationQueue {
"""
count will return the number of Comments that are in this queue.
"""
count: Int!
"""
comments are the comments on the ModerationQueue.
"""
comments(first: Int = 10, after: Cursor): CommentsConnection!
}
"""
ModerationQueues are the list of ModerationQueue's that are supported inside
Talk that can be used to moderate Comments.
"""
type ModerationQueues {
"""
unmoderated will return a ModerationQueue for all Comments that have not been
moderated yet.
"""
unmoderated: ModerationQueue!
"""
reported will return a ModerationQueue for all Comments that have been
published, have not been moderated by a human yet, and have been reported by
a User via a flag.
"""
reported: ModerationQueue!
"""
pending will return a ModerationQueue for all Comments that were held back by
the system and require moderation in order to be published.
"""
pending: ModerationQueue!
}
################################################################################
## Auth
################################################################################
@@ -1249,7 +1293,7 @@ type PageInfo {
}
"""
CommentEdge represents a unique Comment in a CommentConnection.
CommentEdge represents a unique Comment in a CommentsConnection.
"""
type CommentEdge {
"""
@@ -1415,6 +1459,12 @@ type Story {
"""
commentActionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR])
"""
moderationQueues returns the set of ModerationQueues that are available for
this Story.
"""
moderationQueues: ModerationQueues @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
"""
@@ -1466,6 +1516,23 @@ type StoriesConnection {
pageInfo: PageInfo!
}
################################################################################
## CommentsFilterInput
################################################################################
input CommentsFilterInput {
"""
storyID when specified, will filter to show only Comment's on that Story.
"""
storyID: ID
"""
status when specified, will filter to show only Comment's with that
COMMENT_STATUS.
"""
status: COMMENT_STATUS
}
################################################################################
## Query
################################################################################
@@ -1476,6 +1543,15 @@ type Query {
"""
comment(id: ID!): Comment
"""
comments returns a filtered comments connection that can be paginated.
"""
comments(
first: Int = 10
after: Cursor
filter: CommentsFilterInput!
): CommentsConnection @auth(roles: [ADMIN, MODERATOR])
"""
story is the Story specified by its ID/URL.
"""
@@ -1510,6 +1586,12 @@ type Query {
on this scrape.
"""
debugScrapeStoryMetadata(url: String!): StoryMetadata @auth(roles: [ADMIN])
"""
moderationQueues returns the set of ModerationQueues that are available for
all stories.
"""
moderationQueues: ModerationQueues @auth(roles: [ADMIN, MODERATOR])
}
################################################################################
@@ -2233,34 +2315,6 @@ type CreateCommentFlagPayload {
clientMutationId: String!
}
##################
## removeCommentFlag
##################
input RemoveCommentFlagInput {
"""
commentID is the Comment's ID that we want to remove a Flag on.
"""
commentID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveCommentFlagPayload {
"""
comment is the Comment that the Flag was removed on.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## regenerateSSOKey
##################
@@ -2936,13 +2990,6 @@ type Mutation {
createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload
@auth
"""
removeCommentFlag will create a Flag authored by the current logged in User on
a given Comment.
"""
removeCommentFlag(input: RemoveCommentFlagInput!): RemoveCommentFlagPayload
@auth
"""
createStory will create the provided Story.
"""