mirror of
https://github.com/wassname/talk.git
synced 2026-08-01 13:00:55 +08:00
[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:
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user