[next] Comment Moderation Actions (#2068)

* fix: renamed snake case to camel case

* fix: changed case for mutators

* fix: renamed all snake case to camel case for db

* feat: added support for comment revisions + split comment actions

* fix: updated tests

* feat: implemented CommentModerationAction

* fix: fixed case issues

* feat: enabled WeakMap for wordList processsing

* chore: npm audit
This commit is contained in:
Wyatt Johnson
2018-11-21 16:42:47 +00:00
committed by Kiwi
parent 13147c4ba4
commit 21e1a5cbef
66 changed files with 2323 additions and 2224 deletions
+867 -1404
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -142,7 +142,7 @@
"@types/jsdom": "^11.0.6",
"@types/jsonwebtoken": "^7.2.7",
"@types/linkify-it": "^2.0.3",
"@types/lodash": "^4.14.111",
"@types/lodash": "^4.14.118",
"@types/luxon": "^0.5.3",
"@types/mini-css-extract-plugin": "^0.2.0",
"@types/mongodb": "^3.1.14",
@@ -254,7 +254,7 @@
"relay-compiler-language-typescript": "^1.1.0",
"relay-local-schema": "^0.7.0",
"relay-runtime": "^1.7.0-rc.1",
"sane": "^2.5.2",
"sane": "^4.0.2",
"simulant": "^0.2.2",
"sinon": "^6.1.5",
"style-loader": "^0.21.0",
@@ -39,6 +39,7 @@ class ReactionButtonContainer extends React.Component<
const input = {
commentID: this.props.comment.id,
commentRevisionID: this.props.comment.revision.id,
};
const { createCommentReaction, removeCommentReaction } = this.props;
@@ -50,6 +51,7 @@ class ReactionButtonContainer extends React.Component<
? removeCommentReaction(input)
: createCommentReaction(input);
};
public render() {
const {
actionCounts: {
@@ -90,6 +92,9 @@ export default withShowAuthPopupMutation(
comment: graphql`
fragment ReactionButtonContainer_comment on Comment {
id
revision {
id
}
myActionPresence {
reaction
}
@@ -106,7 +106,7 @@ it("cancel edit", async () => {
.findByProps({ id: "comments-commentContainer-editButton-comment-0" })
.props.onClick();
// Cacnel edit form.
// Cancel edit form.
testRenderer.root
.findByProps({ id: "comments-editCommentForm-cancelButton-comment-0" })
.props.onClick();
+3
View File
@@ -52,6 +52,9 @@ export const users = [
export const baseComment = {
author: users[0],
body: "Comment Body",
revision: {
id: "revision-0",
},
createdAt: "2018-07-06T18:24:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
@@ -71,7 +71,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified: emailVerified,
emailVerified,
avatar,
profiles: [profile],
});
@@ -84,7 +84,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified: emailVerified,
emailVerified,
avatar,
profiles: [profile],
});
@@ -185,7 +185,7 @@ export async function findOrCreateOIDCUser(
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified,
emailVerified: email_verified,
avatar: picture,
profiles: [profile],
});
@@ -0,0 +1,23 @@
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,
},
}),
});
@@ -8,10 +8,7 @@ import {
GQLCOMMENT_SORT,
StoryToCommentsArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
retrieveManyUserActionPresence,
} from "talk-server/models/action";
import { retrieveManyUserActionPresence } from "talk-server/models/action/comment";
import {
Comment,
retrieveCommentParentsConnection,
@@ -44,14 +41,13 @@ export default (ctx: Context) => ({
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(itemIDs: string[]) =>
(commentIDs: string[]) =>
retrieveManyUserActionPresence(
ctx.mongo,
ctx.tenant.id,
// This should only ever be accessed when a user is logged in.
ctx.user!.id,
ACTION_ITEM_TYPE.COMMENTS,
itemIDs
commentIDs
)
),
forUser: (
@@ -1,12 +1,14 @@
import Context from "talk-server/graph/tenant/context";
import Auth from "./auth";
import Comments from "./comments";
import Stories from "./stories";
import Users from "./users";
import Actions from "./Actions";
import Auth from "./Auth";
import Comments from "./Comments";
import Stories from "./Stories";
import Users from "./Users";
export default (ctx: Context) => ({
Auth: Auth(ctx),
Actions: Actions(ctx),
Stories: Stories(ctx),
Comments: Comments(ctx),
Users: Users(ctx),
@@ -0,0 +1,21 @@
import TenantContext from "talk-server/graph/tenant/context";
import { accept, reject } from "talk-server/services/moderation";
import {
GQLAcceptCommentInput,
GQLRejectCommentInput,
} from "../schema/__generated__/types";
export const Actions = (ctx: TenantContext) => ({
acceptComment: (input: GQLAcceptCommentInput) =>
accept(ctx.mongo, ctx.tenant, {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
moderatorID: ctx.user!.id,
}),
rejectComment: (input: GQLRejectCommentInput) =>
reject(ctx.mongo, ctx.tenant, {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
moderatorID: ctx.user!.id,
}),
});
@@ -19,54 +19,62 @@ import {
removeReaction,
} from "talk-server/services/comments/actions";
export default (ctx: TenantContext) => ({
create: (input: GQLCreateCommentInput) =>
export const Comment = (ctx: TenantContext) => ({
create: ({ storyID, body, parentID }: GQLCreateCommentInput) =>
create(
ctx.mongo,
ctx.tenant,
ctx.user!,
{
author_id: ctx.user!.id,
story_id: input.storyID,
body: input.body,
parent_id: input.parentID,
},
{ authorID: ctx.user!.id, storyID, body, parentID },
ctx.req
),
edit: (input: GQLEditCommentInput) =>
edit: ({ commentID, body }: GQLEditCommentInput) =>
edit(
ctx.mongo,
ctx.tenant,
ctx.user!,
{
id: input.commentID,
body: input.body,
id: commentID,
body,
},
ctx.req
),
createReaction: (input: GQLCreateCommentReactionInput) =>
createReaction: ({
commentID,
commentRevisionID,
}: GQLCreateCommentReactionInput) =>
createReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
commentID,
commentRevisionID,
}),
removeReaction: (input: GQLRemoveCommentReactionInput) =>
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
commentID,
}),
createDontAgree: (input: GQLCreateCommentDontAgreeInput) =>
createDontAgree: ({
commentID,
commentRevisionID,
}: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
commentID,
commentRevisionID,
}),
removeDontAgree: (input: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
commentID,
}),
createFlag: (input: GQLCreateCommentFlagInput) =>
createFlag: ({
commentID,
commentRevisionID,
reason,
}: GQLCreateCommentFlagInput) =>
createFlag(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
reason: input.reason,
commentID,
commentRevisionID,
reason,
}),
removeFlag: (input: GQLRemoveCommentFlagInput) =>
removeFlag: ({ commentID }: GQLRemoveCommentFlagInput) =>
removeFlag(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
commentID,
}),
});
@@ -16,7 +16,12 @@ import {
updateOIDCAuthIntegration,
} from "talk-server/services/tenant";
export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({
export const Settings = ({
mongo,
redis,
tenantCache,
tenant,
}: TenantContext) => ({
update: (input: GQLSettingsInput): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
regenerateSSOKey: (): Promise<Tenant | null> =>
@@ -8,12 +8,14 @@ import {
GQLScrapeStoryInput,
GQLUpdateStoryInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Story } from "talk-server/models/story";
import * as 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 default (ctx: TenantContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
export const Story = (ctx: TenantContext) => ({
create: async (
input: GQLCreateStoryInput
): Promise<Readonly<story.Story> | null> =>
create(
ctx.mongo,
ctx.tenant,
@@ -21,12 +23,20 @@ export default (ctx: TenantContext) => ({
input.story.url,
omitBy(input.story, isNull)
),
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
update: async (
input: GQLUpdateStoryInput
): Promise<Readonly<story.Story> | null> =>
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs),
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove: async (
input: GQLRemoveStoryInput
): Promise<Readonly<story.Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape: async (
input: GQLScrapeStoryInput
): Promise<Readonly<story.Story> | null> =>
scrape(ctx.mongo, ctx.tenant.id, input.id),
});
@@ -1,10 +1,12 @@
import TenantContext from "talk-server/graph/tenant/context";
import Comment from "./comment";
import Settings from "./settings";
import Story from "./story";
import { Actions } from "./Actions";
import { Comment } from "./Comment";
import { Settings } from "./Settings";
import { Story } from "./Story";
export default (ctx: TenantContext) => ({
Actions: Actions(ctx),
Comment: Comment(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
@@ -5,12 +5,12 @@ import {
const disabled = { enabled: false };
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<GQLAuthIntegrations> = {
export const AuthIntegrations: GQLAuthIntegrationsTypeResolver<
GQLAuthIntegrations
> = {
local: auth => auth.local || disabled,
sso: auth => auth.sso || disabled,
oidc: auth => auth.oidc || disabled,
google: auth => auth.google || disabled,
facebook: auth => auth.facebook || disabled,
};
export default AuthIntegrations;
@@ -0,0 +1,80 @@
import { GraphQLResolveInfo } from "graphql";
import { getRequestedFields } from "talk-server/graph/tenant/resolvers/util";
import {
GQLComment,
GQLCommentTypeResolver,
} 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 { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/connection";
import TenantContext from "../context";
const maybeLoadOnlyID = (
ctx: TenantContext,
info: GraphQLResolveInfo,
id?: string
) => {
// If there isn't an id, then return nothing!
if (!id) {
return null;
}
// Get the field names of the fields being requested, if it's only the ID,
// we have that, so no need to make a database request.
const fields = getRequestedFields<GQLComment>(info);
if (fields.length === 1 && fields[0] === "id") {
return {
id,
};
}
// We want more than the ID! Get the comment!
// TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o.
return ctx.loaders.Comments.comment.load(id);
};
export const Comment: GQLCommentTypeResolver<comment.Comment> = {
body: c => getLatestRevision(c).body,
// Send the whole comment back when you request revisions. This way, we get to
// know the comment ID. The field mapping is handled by the CommentRevision
// resolver.
revision: c => ({ revision: getLatestRevision(c), comment: c }),
revisionHistory: c => c.revisions.map(revision => ({ revision, comment: c })),
editing: ({ revisions, createdAt }, input, ctx) => ({
// When there is more than one body history, then the comment has been
// edited.
edited: revisions.length > 1,
// The date that the comment is editable until is the tenant's edit window
// length added to the comment created date.
editableUntil: new Date(
createdAt.valueOf() + ctx.tenant.editCommentWindowLength
),
}),
author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID),
statusHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActions({
commentID: id,
}),
replies: (c, input, ctx) =>
c.replyCount > 0
? ctx.loaders.Comments.forParent(c.storyID, c.id, input)
: createConnection(),
actionCounts: c => decodeActionCounts(c.actionCounts),
myActionPresence: (c, input, ctx) =>
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
parentCount: c => (c.parentID ? c.grandparentIDs.length + 1 : 0),
depth: c => (c.parentID ? c.grandparentIDs.length + 1 : 0),
rootParent: (c, input, ctx, info) =>
maybeLoadOnlyID(
ctx,
info,
c.grandparentIDs.length > 0 ? c.grandparentIDs[0] : c.parentID
),
parent: (c, input, ctx, info) => maybeLoadOnlyID(ctx, info, c.parentID),
parents: (c, input, ctx) =>
// Some resolver optimization.
c.parentID ? ctx.loaders.Comments.parents(c, input) : createConnection(),
story: (c, input, ctx) => ctx.loaders.Stories.story.load(c.storyID),
};
@@ -4,11 +4,11 @@ import {
} from "talk-server/graph/tenant/schema/__generated__/types";
import { CommentStatusCounts } from "talk-server/models/story";
const CommentCounts: GQLCommentCountsTypeResolver<CommentStatusCounts> = {
export const CommentCounts: GQLCommentCountsTypeResolver<
CommentStatusCounts
> = {
totalVisible: commentCounts =>
commentCounts[GQLCOMMENT_STATUS.ACCEPTED] +
commentCounts[GQLCOMMENT_STATUS.NONE],
statuses: commentCounts => commentCounts,
};
export default CommentCounts;
@@ -0,0 +1,24 @@
import * as actions from "talk-server/models/action/moderation/comment";
import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types";
export const CommentModerationAction: GQLCommentModerationActionTypeResolver<
actions.CommentModerationAction
> = {
revision: async (action, input, ctx) => {
const comment = await ctx.loaders.Comments.comment.load(action.commentID);
if (!comment) {
return null;
}
const revision = comment.revisions.find(
({ id }) => id === action.commentRevisionID
);
if (!revision) {
return null;
}
return { comment, revision };
},
moderator: (action, input, ctx) =>
action.moderatorID ? ctx.loaders.Users.user.load(action.moderatorID) : null,
};
@@ -0,0 +1,18 @@
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";
export interface WrappedCommentRevision {
revision: comment.Revision;
comment: comment.Comment;
}
export const CommentRevision: Required<
GQLCommentRevisionTypeResolver<WrappedCommentRevision>
> = {
id: w => w.revision.id,
comment: w => w.comment,
actionCounts: w => decodeActionCounts(w.revision.actionCounts),
body: w => w.revision.body,
createdAt: w => w.revision.createdAt,
};
@@ -4,7 +4,7 @@ import {
GQLFacebookAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
GQLFacebookAuthIntegration
> = {
callbackURL: (integration, args, ctx) => {
@@ -22,5 +22,3 @@ const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
return constructTenantURL(ctx.config, ctx.tenant, path);
},
};
export default FacebookAuthIntegration;
@@ -4,7 +4,7 @@ import {
GQLGoogleAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GQLGoogleAuthIntegration
> = {
callbackURL: (integration, args, ctx) => {
@@ -22,5 +22,3 @@ const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
return constructTenantURL(ctx.config, ctx.tenant, path);
},
};
export default GoogleAuthIntegration;
@@ -1,6 +1,6 @@
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
const Mutation: GQLMutationTypeResolver<void> = {
export const Mutation: GQLMutationTypeResolver<void> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
clientMutationId: input.clientMutationId,
@@ -107,6 +107,12 @@ const Mutation: GQLMutationTypeResolver<void> = {
story: await ctx.mutators.Story.scrape(input),
clientMutationId: input.clientMutationId,
}),
acceptComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Actions.acceptComment(input),
clientMutationId: input.clientMutationId,
}),
rejectComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Actions.rejectComment(input),
clientMutationId: input.clientMutationId,
}),
};
export default Mutation;
@@ -4,7 +4,7 @@ import {
GQLOIDCAuthIntegrationTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
GQLOIDCAuthIntegration
> = {
callbackURL: (integration, args, ctx) => {
@@ -22,5 +22,3 @@ const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
return constructTenantURL(ctx.config, ctx.tenant, path);
},
};
export default OIDCAuthIntegration;
@@ -1,8 +1,8 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Profile } from "talk-server/models/user";
import * as user from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<Profile> = profile => {
const resolveType: GQLProfileTypeResolver<user.Profile> = profile => {
switch (profile.type) {
case "local":
return "LocalProfile";
@@ -16,6 +16,6 @@ const resolveType: GQLProfileTypeResolver<Profile> = profile => {
}
};
export default {
export const Profile = {
__resolveType: resolveType,
};
@@ -1,6 +1,6 @@
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
const Query: GQLQueryTypeResolver<void> = {
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,
@@ -11,5 +11,3 @@ const Query: GQLQueryTypeResolver<void> = {
debugScrapeStoryMetadata: (source, { url }, ctx) =>
ctx.loaders.Stories.debugScrapeMetadata.load(url),
};
export default Query;
@@ -0,0 +1,25 @@
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";
export const Story: GQLStoryTypeResolver<story.Story> = {
comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input),
isClosed: () => false,
closedAt: (s, input, ctx) => {
if (s.closedAt) {
return s.closedAt;
}
if (ctx.tenant.autoCloseStream && ctx.tenant.closedTimeout) {
return DateTime.fromJSDate(s.createdAt)
.plus(ctx.tenant.closedTimeout)
.toJSDate();
}
return null;
},
commentActionCounts: s => decodeActionCounts(s.commentActionCounts),
commentCounts: s => s.commentCounts,
};
@@ -0,0 +1,8 @@
import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
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),
};
@@ -1,91 +0,0 @@
import { getRequestedFields } from "talk-server/graph/tenant/resolvers/util";
import {
GQLComment,
GQLCommentTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action";
import { Comment } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/connection";
const Comment: GQLCommentTypeResolver<Comment> = {
editing: (comment, input, ctx) => ({
// When there is more than one body history, then the comment has been
// edited.
edited: comment.body_history.length > 1,
// The date that the comment is editable until is the tenant's edit window
// length added to the comment created date.
editableUntil: new Date(
comment.created_at.valueOf() + ctx.tenant.editCommentWindowLength
),
}),
createdAt: comment => comment.created_at,
author: (comment, input, ctx) =>
ctx.loaders.Users.user.load(comment.author_id),
replies: (comment, input, ctx) =>
comment.reply_count > 0
? ctx.loaders.Comments.forParent(comment.story_id, comment.id, input)
: createConnection(),
actionCounts: comment => decodeActionCounts(comment.action_counts),
myActionPresence: (comment, input, ctx) =>
ctx.user
? ctx.loaders.Comments.retrieveMyActionPresence.load(comment.id)
: null,
parentCount: comment =>
comment.parent_id ? comment.grandparent_ids.length + 1 : 0,
depth: comment =>
comment.parent_id ? comment.grandparent_ids.length + 1 : 0,
replyCount: comment => comment.reply_count,
rootParent: (comment, input, ctx, info) => {
// If there isn't a parent, then return nothing!
if (!comment.parent_id) {
return null;
}
// rootParentID is the root parent id for a given comment.
const rootParentID =
comment.grandparent_ids.length > 0
? comment.grandparent_ids[0]
: comment.parent_id;
// Get the field names of the fields being requested, if it's only the ID,
// we have that, so no need to make a database request.
const fields = getRequestedFields<GQLComment>(info);
if (fields.length === 1 && fields[0] === "id") {
return {
id: rootParentID,
};
}
// We want more than the ID! Get the comment!
// TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o.
return ctx.loaders.Comments.comment.load(rootParentID);
},
parent: (comment, input, ctx, info) => {
// If there isn't a parent, then return nothing!
if (!comment.parent_id) {
return null;
}
// Get the field names of the fields being requested, if it's only the ID,
// we have that, so no need to make a database request.
const fields = getRequestedFields<GQLComment>(info);
if (fields.length === 1 && fields[0] === "id") {
return {
id: comment.parent_id,
};
}
// We want more than the ID! Get the comment!
// TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o.
return ctx.loaders.Comments.comment.load(comment.parent_id);
},
parents: (comment, input, ctx) =>
// Some resolver optimization.
comment.parent_id
? ctx.loaders.Comments.parents(comment, input)
: createConnection(),
story: (comment, input, ctx) =>
ctx.loaders.Stories.story.load(comment.story_id),
};
export default Comment;
+15 -11
View File
@@ -3,22 +3,26 @@ import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import AuthIntegrations from "./auth_integrations";
import Comment from "./comment";
import CommentCounts from "./comment_counts";
import FacebookAuthIntegration from "./facebook_auth_integration";
import GoogleAuthIntegration from "./google_auth_integration";
import Mutation from "./mutation";
import OIDCAuthIntegration from "./oidc_auth_integration";
import Profile from "./profile";
import Query from "./query";
import Story from "./story";
import User from "./user";
import { AuthIntegrations } from "./AuthIntegrations";
import { Comment } from "./Comment";
import { CommentCounts } from "./CommentCounts";
import { CommentModerationAction } from "./CommentModerationAction";
import { CommentRevision } from "./CommentRevision";
import { FacebookAuthIntegration } from "./FacebookAuthIntegration";
import { GoogleAuthIntegration } from "./GoogleAuthIntegration";
import { Mutation } from "./Mutation";
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
import { Profile } from "./Profile";
import { Query } from "./Query";
import { Story } from "./Story";
import { User } from "./User";
const Resolvers: GQLResolver = {
AuthIntegrations,
Comment,
CommentCounts,
CommentModerationAction,
CommentRevision,
Cursor,
Mutation,
OIDCAuthIntegration,
@@ -1,28 +0,0 @@
import { DateTime } from "luxon";
import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action";
import { Story } from "talk-server/models/story";
const Story: GQLStoryTypeResolver<Story> = {
comments: (story, input, ctx) =>
ctx.loaders.Comments.forStory(story.id, input),
isClosed: () => false,
closedAt: (story, input, ctx) => {
if (story.closedAt) {
return story.closedAt;
}
if (ctx.tenant.autoCloseStream && ctx.tenant.closedTimeout) {
return DateTime.fromJSDate(story.created_at)
.plus(ctx.tenant.closedTimeout)
.toJSDate();
}
return null;
},
actionCounts: story => decodeActionCounts(story.action_counts),
commentCounts: story => story.comment_counts,
};
export default Story;
@@ -1,8 +0,0 @@
import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { User } from "talk-server/models/user";
const User: GQLUserTypeResolver<User> = {
comments: (user, input, ctx) => ctx.loaders.Comments.forUser(user.id, input),
};
export default User;
@@ -975,6 +975,15 @@ type User {
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
): CommentsConnection! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
commentModerationActionHistory returns a CommentModerationActionConnection that this User has
created.
"""
commentModerationActionHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN])
}
################################################################################
@@ -1024,6 +1033,85 @@ enum COMMENT_STATUS {
SYSTEM_WITHHELD
}
type CommentModerationAction {
id: ID!
"""
revision is the moderated CommentRevision.
"""
revision: CommentRevision!
"""
status represents the status that was assigned by the moderator.
"""
status: COMMENT_STATUS!
"""
moderator is the User that performed the Moderator action. If null, this means
that the system has assigned the moderation status.
"""
moderator: User
"""
createdAt is the time that the CommentModerationAction was created.
"""
createdAt: Time!
}
type CommentModerationActionEdge {
"""
node is the CommentModerationAction for this edge.
"""
node: CommentModerationAction!
"""
"""
cursor: Cursor
}
type CommentModerationActionConnection {
"""
edges are a subset of CommentModerationActionEdge's.
"""
edges: [CommentModerationActionEdge!]!
"""
pageInfo is
"""
pageInfo: PageInfo!
}
type CommentRevision {
"""
id is the identifier of the CommentRevision.
"""
id: ID!
"""
comment is the reference to the original Comment associated with the current
Comment.
"""
comment: Comment!
"""
actionCounts stores the counts of all the actions for the CommentRevision
specifically.
"""
actionCounts: ActionCounts! @auth(roles: [MODERATOR, ADMIN])
"""
body is the content of the CommentRevision. If null, it indicates that the
body text was deleted.
"""
body: String
"""
createdAt is the time that the CommentRevision was created.
"""
createdAt: Time!
}
"""
Comment is a comment left by a User on an Story or another Comment as a reply.
"""
@@ -1034,10 +1122,23 @@ type Comment {
id: ID!
"""
body is the content of the Comment.
body is the content of the Comment, and is an alias to the body of the
`currentRevision.body`.
"""
body: String
"""
revision is the current revision of the Comment's body.
"""
revision: CommentRevision!
"""
revisionHistory stores the previous CommentRevision's, with the most recent
edit last.
"""
revisionHistory: [CommentRevision!]!
@auth(roles: [MODERATOR, ADMIN], userIDField: "author_id")
"""
createdAt is the date in which the Comment was created.
"""
@@ -1053,6 +1154,13 @@ type Comment {
"""
status: COMMENT_STATUS!
"""
statusHistory returns a CommentModerationActionConnection that will list
the history of moderator actions performed on the Comment, with the most
recent last.
"""
statusHistory: [CommentModerationAction!]! @auth(role: [MODERATOR, ADMIN])
"""
parentCount is the number of direct parents for this Comment. Currently this
value is the same as depth.
@@ -1107,7 +1215,7 @@ type Comment {
actionCounts: ActionCounts!
"""
myActionPresence stores the presense information for all the actions
myActionPresence stores the presence information for all the actions
left by the current User on this Comment.
"""
myActionPresence: ActionPresence
@@ -1302,10 +1410,10 @@ type Story {
): CommentsConnection!
"""
actionCounts stores the counts of all the actions against this Story and it's
commentActionCounts stores the counts of all the actions against this Story and it's
Comments.
"""
actionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR])
commentActionCounts: ActionCounts! @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
@@ -1927,6 +2035,12 @@ input CreateCommentReactionInput {
"""
commentID: ID!
"""
commentRevisionID is the revision ID of the Comment that we're creating the
Reaction on.
"""
commentRevisionID: ID!
"""
clientMutationId is required for Relay support.
"""
@@ -1983,6 +2097,12 @@ input CreateCommentDontAgreeInput {
"""
commentID: ID!
"""
commentRevisionID is the revision ID of the Comment that we're creating the
DontAgree on.
"""
commentRevisionID: ID!
"""
clientMutationId is required for Relay support.
"""
@@ -2039,6 +2159,12 @@ input CreateCommentFlagInput {
"""
commentID: ID!
"""
commentRevisionID is the revision ID of the Comment that we're creating the
Flag on.
"""
commentRevisionID: ID!
"""
reason is the selected reason why the Flag is being created.
"""
@@ -2603,6 +2729,72 @@ type ScrapeStoryPayload {
clientMutationId: String!
}
##################
# acceptComment
##################
input AcceptCommentInput {
"""
commentID is the ID of the Comment that was accepted.
"""
commentID: ID!
"""
commentRevisionID is the ID of the CommentRevision that is being accepted.
"""
commentRevisionID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type AcceptCommentPayload {
"""
comment is the Comment that was accepted.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# rejectComment
##################
input RejectCommentInput {
"""
commentID is the ID of the Comment that was rejected.
"""
commentID: ID!
"""
commentRevisionID is the ID of the CommentRevision that is being rejected.
"""
commentRevisionID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RejectCommentPayload {
"""
comment is the Comment that was rejected.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -2730,6 +2922,18 @@ type Mutation {
"""
scrapeStory(input: ScrapeStoryInput!): ScrapeStoryPayload
@auth(roles: [ADMIN, MODERATOR])
"""
acceptComment will mark the Comment as ACCEPTED.
"""
acceptComment(input: AcceptCommentInput!): AcceptCommentPayload
@auth(roles: [MODERATOR, ADMIN])
"""
rejectComment will mark the Comment as REJECTED.
"""
rejectComment(input: RejectCommentInput!): RejectCommentPayload
@auth(roles: [MODERATOR, ADMIN])
}
################################################################################
@@ -1,27 +1,26 @@
import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Action,
ACTION_ITEM_TYPE,
ACTION_TYPE,
CommentAction,
decodeActionCounts,
encodeActionCounts,
validateAction,
} from "talk-server/models/action";
} from "talk-server/models/action/comment";
describe("#encodeActionCounts", () => {
it("generates the action counts correctly", () => {
const actions = [
{ action_type: ACTION_TYPE.DONT_AGREE },
const actions: Array<Partial<CommentAction>> = [
{ actionType: ACTION_TYPE.DONT_AGREE },
{
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const actionCounts = encodeActionCounts(...(actions as Action[]));
const actionCounts = encodeActionCounts(...(actions as CommentAction[]));
expect(actionCounts).toMatchSnapshot();
});
@@ -29,22 +28,24 @@ describe("#encodeActionCounts", () => {
describe("#decodeActionCounts", () => {
it("parses the action counts correctly", () => {
const actions = [
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.REACTION },
{ action_type: ACTION_TYPE.DONT_AGREE },
const actions: Array<Partial<CommentAction>> = [
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.DONT_AGREE },
{
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const modelActionCounts = encodeActionCounts(...(actions as Action[]));
const modelActionCounts = encodeActionCounts(
...(actions as CommentAction[])
);
expect(modelActionCounts).toMatchSnapshot();
@@ -56,77 +57,65 @@ describe("#decodeActionCounts", () => {
describe("#validateAction", () => {
it("allows a valid action", () => {
const actions = [
const actions: Array<Partial<CommentAction>> = [
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.REACTION,
actionType: ACTION_TYPE.REACTION,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
actionType: ACTION_TYPE.DONT_AGREE,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
];
for (const action of actions) {
validateAction(action as Action);
validateAction(action as CommentAction);
}
});
it("does not allow an invalid action", () => {
const actions = [
const actions: Array<Partial<CommentAction>> = [
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
actionType: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
actionType: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
},
];
for (const action of actions) {
expect(() => validateAction(action as Action)).toThrow();
expect(() => validateAction(action as CommentAction)).toThrow();
}
});
});
@@ -15,7 +15,7 @@ import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<Action>>("actions");
return db.collection<Readonly<CommentAction>>("commentActions");
}
export enum ACTION_TYPE {
@@ -37,15 +37,7 @@ export enum ACTION_TYPE {
FLAG = "FLAG",
}
export type EncodedActionCounts = Record<string, number>;
export interface ActionCountGroup {
total: number;
}
export enum ACTION_ITEM_TYPE {
COMMENTS = "COMMENTS",
}
export type EncodedCommentActionCounts = Record<string, number>;
/**
* FLAG_REASON is the reason that a given Flag has been created.
@@ -55,27 +47,27 @@ export type FLAG_REASON =
| GQLCOMMENT_FLAG_REPORTED_REASON
| GQLCOMMENT_FLAG_REASON;
export interface Action extends TenantResource {
export interface CommentAction extends TenantResource {
/**
* id is the identifier for this specific Action.
*/
readonly id: string;
/**
* action_type is the type of Action that this represents.
* actionType is the type of Action that this represents.
*/
action_type: ACTION_TYPE;
actionType: ACTION_TYPE;
/**
* item_type enables polymorphic behavior be allowing multiple item types
* to be represented in a single collection.
* commentID is the ID of the specific item that this Action is associated with.
*/
item_type: ACTION_ITEM_TYPE;
commentID: string;
/**
* item_id is the ID of the specific item that this Action is associated with.
* commentRevisionID is the ID of the specific comment text that the Action
* is relating to.
*/
item_id: string;
commentRevisionID: string;
/**
* reason is the reason or secondary grouping identifier for why this
@@ -84,22 +76,22 @@ export interface Action extends TenantResource {
reason?: FLAG_REASON;
/**
* root_item_id represents the identifier for the item's associated item. In
* storyID represents the identifier for the item's associated item. In
* the case of a REACTION left on a Comment, this ID would be the Stories ID.
* In the case of a FLAG left on a User, this ID would be null.
*/
root_item_id?: string;
storyID?: string;
/**
* user_id is the ID of the User that left this Action. In the event that the
* userID is the ID of the User that left this Action. In the event that the
* Action was left by Talk, it will be null.
*/
user_id?: string;
userID: string | null;
/**
* created_at is the date that this particular Action was created at.
* createdAt is the date that this particular Action was created at.
*/
created_at: Date;
createdAt: Date;
/**
* metadata is arbitrary information stored for this Action.
@@ -110,21 +102,18 @@ export interface Action extends TenantResource {
const ActionSchema = [
// Flags
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
// Only reasons for the flag action will be allowed here, and it must be
// specified.
reason: Object.keys(GQLCOMMENT_FLAG_REASON),
},
// Don't Agree
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.DONT_AGREE,
actionType: ACTION_TYPE.DONT_AGREE,
},
// Reaction
{
item_type: ACTION_ITEM_TYPE.COMMENTS,
action_type: ACTION_TYPE.REACTION,
actionType: ACTION_TYPE.REACTION,
},
];
@@ -133,12 +122,12 @@ const ActionSchema = [
* expected schema, `ActionSchema`.
*/
export function validateAction(
action: Pick<Action, "item_type" | "action_type" | "reason">
action: Pick<CommentAction, "actionType" | "reason">
) {
const { error } = Joi.validate(
// In typescript, this isn't an issue, but when this is transpiled to
// javascript, it will contain additional elements.
pick(action, ["item_type", "action_type", "reason"]),
pick(action, ["actionType", "reason"]),
ActionSchema,
{
presence: "required",
@@ -151,13 +140,16 @@ export function validateAction(
}
}
export type CreateActionInput = Omit<Action, "id" | "tenant_id" | "created_at">;
export type CreateActionInput = Omit<
CommentAction,
"id" | "tenantID" | "createdAt"
>;
export interface CreateActionResultObject {
/**
* action contains the resultant action that was created.
*/
action: Action;
action: CommentAction;
/**
* wasUpserted when true, indicates that this action was just newly created.
@@ -172,35 +164,27 @@ export async function createAction(
tenantID: string,
input: CreateActionInput
): Promise<CreateActionResultObject> {
const { metadata, ...filter } = input;
// Create a new ID for the action.
const id = uuid.v4();
// defaults are the properties set by the application when a new action is
// created.
const defaults: Sub<Action, CreateActionInput> = {
const defaults: Sub<CommentAction, CreateActionInput> = {
id,
tenant_id: tenantID,
created_at: new Date(),
tenantID,
createdAt: new Date(),
};
// Merge the defaults with the input.
const action: Readonly<Action> = {
const action: Readonly<CommentAction> = {
...defaults,
...input,
};
// This filter ensures that a given user can't flag/respect a given user more
// than once.
const filter: FilterQuery<Action> = {
action_type: input.action_type,
item_type: input.item_type,
item_id: input.item_id,
reason: input.reason,
user_id: input.user_id,
};
// Create the upsert/update operation.
const update: { $setOnInsert: Readonly<Action> } = {
const update: { $setOnInsert: Readonly<CommentAction> } = {
$setOnInsert: action,
};
@@ -241,6 +225,21 @@ export async function createActions(
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
}
export async function retrieveUserAction(
mongo: Db,
tenantID: string,
userID: string | null,
commentID: string,
actionType: ACTION_TYPE
) {
return collection(mongo).findOne({
tenantID,
commentID,
userID,
actionType,
});
}
/**
* retrieveManyUserActionPresence returns the action presence for a specific
* user.
@@ -249,21 +248,19 @@ export async function retrieveManyUserActionPresence(
mongo: Db,
tenantID: string,
userID: string | null,
itemType: ACTION_ITEM_TYPE,
itemIDs: string[]
commentIDs: string[]
): Promise<GQLActionPresence[]> {
const cursor = await collection(mongo).find(
{
tenant_id: tenantID,
user_id: userID,
item_type: itemType,
item_id: { $in: itemIDs },
tenantID,
userID,
commentID: { $in: commentIDs },
},
{
// We only need the item_id and action_type from the database.
// We only need the commentID and actionType from the database.
projection: {
item_id: 1,
action_type: 1,
commentID: 1,
actionType: 1,
},
}
);
@@ -272,13 +269,13 @@ export async function retrieveManyUserActionPresence(
// For each of the actions returned by the query, group the actions by the
// item id. Then compute the action presence for each of the actions.
return itemIDs
.map(itemID => actions.filter(action => action.item_id === itemID))
return commentIDs
.map(commentID => actions.filter(action => action.commentID === commentID))
.map(itemActions =>
itemActions.reduce(
(actionPresence, { action_type }) => ({
(actionPresence, { actionType }) => ({
...actionPresence,
[camelCase(action_type)]: true,
[camelCase(actionType)]: true,
}),
{
reaction: false,
@@ -290,8 +287,8 @@ export async function retrieveManyUserActionPresence(
}
export type RemoveActionInput = Pick<
Action,
"action_type" | "item_type" | "item_id" | "reason" | "user_id"
CommentAction,
"actionType" | "commentID" | "commentRevisionID" | "reason" | "userID"
>;
/**
@@ -301,7 +298,7 @@ export interface RemovedActionResultObject {
/**
* action is the action that was deleted.
*/
action?: Action;
action?: CommentAction;
/**
* wasRemoved is true when the action that was supposed to be deleted was
@@ -319,19 +316,18 @@ export async function removeAction(
tenantID: string,
input: RemoveActionInput
): Promise<RemovedActionResultObject> {
const { reason, ...rest } = input;
// Extract the filter parameters.
const filter: FilterQuery<Action> = {
tenant_id: tenantID,
action_type: input.action_type,
item_type: input.item_type,
item_id: input.item_id,
user_id: input.user_id,
const filter: FilterQuery<CommentAction> = {
tenantID,
...rest,
};
// Only add the reason to the filter if it's been specified, otherwise we'll
// never match a Flag that has an unspecified reason.
if (input.reason) {
filter.reason = input.reason;
if (reason) {
filter.reason = reason;
}
// Remove the action from the database, returning the action that was deleted.
@@ -354,8 +350,10 @@ export const ACTION_COUNT_JOIN_CHAR = "__";
*
* @param actions list of actions to generate the action counts from
*/
export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
const actionCounts: EncodedActionCounts = {};
export function encodeActionCounts(
...actions: CommentAction[]
): EncodedCommentActionCounts {
const actionCounts: EncodedCommentActionCounts = {};
// Loop over the actions, and increment them.
for (const action of actions) {
@@ -377,8 +375,8 @@ export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
* @param actionCounts the encoded action counts to invert
*/
export function invertEncodedActionCounts(
actionCounts: EncodedActionCounts
): EncodedActionCounts {
actionCounts: EncodedCommentActionCounts
): EncodedCommentActionCounts {
for (const key in actionCounts) {
if (!actionCounts.hasOwnProperty(key)) {
continue;
@@ -396,11 +394,11 @@ export function invertEncodedActionCounts(
* encodeActionCountKeys encodes the action into string keys which represents
* the groupings as seen in `EncodedActionCounts`.
*/
function encodeActionCountKeys(action: Action): string[] {
const keys = [action.action_type as string];
function encodeActionCountKeys(action: CommentAction): string[] {
const keys = [action.actionType as string];
if (action.reason) {
keys.push(
[action.action_type as string, action.reason as string].join(
[action.actionType as string, action.reason as string].join(
ACTION_COUNT_JOIN_CHAR
)
);
@@ -496,10 +494,10 @@ function createEmptyActionCounts(): GQLActionCounts {
};
}
export function mergeActionCounts(
actionCounts: EncodedActionCounts[]
): EncodedActionCounts {
const mergedActionCounts: EncodedActionCounts = {};
export function mergeCommentActionCounts(
actionCounts: EncodedCommentActionCounts[]
): EncodedCommentActionCounts {
const mergedActionCounts: EncodedCommentActionCounts = {};
for (const counts of actionCounts) {
for (const [key, count] of Object.entries(counts)) {
@@ -515,7 +513,7 @@ export function mergeActionCounts(
}
export function countTotalActionCounts(
actionCounts: EncodedActionCounts
actionCounts: EncodedCommentActionCounts
): number {
return Object.values(actionCounts).reduce((total, count) => total + count, 0);
}
@@ -527,7 +525,7 @@ export function countTotalActionCounts(
* @param encodedActionCounts the action counts to decode
*/
export function decodeActionCounts(
encodedActionCounts: EncodedActionCounts
encodedActionCounts: EncodedCommentActionCounts
): GQLActionCounts {
// Default all the action counts to zero.
const actionCounts: GQLActionCounts = createEmptyActionCounts();
@@ -579,37 +577,37 @@ function incrementActionCounts(
* removeRootActions will remove all the Action's associated with a given root
* identifier.
*/
export async function removeRootActions(
export async function removeStoryActions(
mongo: Db,
tenantID: string,
rootItemID: string
storyID: string
) {
return collection(mongo).deleteMany({
tenant_id: tenantID,
root_item_id: rootItemID,
tenantID,
storyID,
});
}
/**
* mergeManyRootActions will update many Action `root_item_id'`s from one to
* mergeManyRootActions will update many Action `storyID`'s from one to
* another.
*/
export async function mergeManyRootActions(
export async function mergeManyStoryActions(
mongo: Db,
tenantID: string,
newRootItemID: string,
oldRootItemIDs: string[]
newStoryID: string,
oldStoryIDs: string[]
) {
return collection(mongo).updateMany(
{
tenant_id: tenantID,
root_item_id: {
$in: oldRootItemIDs,
tenantID,
storyID: {
$in: oldStoryIDs,
},
},
{
$set: {
root_item_id: newRootItemID,
storyID: newStoryID,
},
}
);
@@ -0,0 +1,173 @@
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Connection,
Cursor,
getPageInfo,
nodesToEdges,
} from "talk-server/models/connection";
import Query from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<CommentModerationAction>>(
"commentModerationActions"
);
}
/**
* CommentModerationAction stores information around a moderation action that
* was created for a given Comment Revision.
*/
export interface CommentModerationAction extends TenantResource {
readonly id: string;
/**
* commentID is the ID of the Comment that the moderation action is based on.
*/
commentID: string;
/**
* commentRevisionID is the ID of the Revision that the moderation action is
* based on.
*/
commentRevisionID: string;
/**
* status is the GQLCOMMENT_STATUS assigned by the moderator for this
* moderation action.
*/
status: GQLCOMMENT_STATUS;
/**
* moderatorID is the ID of the User that created the moderation action. If
* null, it indicates that it was created by the system rather than a User.
*/
moderatorID: string | null;
/**
* createdAt is the time that the moderation action was created on.
*/
createdAt: Date;
}
export type CreateCommentModerationActionInput = Omit<
CommentModerationAction,
"tenantID" | "id" | "createdAt"
>;
export async function createCommentModerationAction(
mongo: Db,
tenantID: string,
input: CreateCommentModerationActionInput
) {
// default are the properties set by the application when a new comment
// moderation action is created.
const defaults: Sub<
CommentModerationAction,
CreateCommentModerationActionInput
> = {
id: uuid.v4(),
tenantID,
createdAt: new Date(),
};
// Merge the defaults and the input together.
const action: Readonly<CommentModerationAction> = {
...defaults,
...input,
};
// Insert it into the database.
await collection(mongo).insertOne(action);
return action;
}
export type CommentModerationActionFilter = Partial<
Pick<
CommentModerationAction,
"commentID" | "commentRevisionID" | "moderatorID" | "status"
>
>;
export async function retrieveCommentModerationActions(
mongo: Db,
tenantID: string,
filter: CommentModerationActionFilter
) {
const result = await collection(mongo).find({
tenantID,
...filter,
});
return result.toArray();
}
export interface ConnectionInput {
first: number;
after?: Cursor;
filter?: CommentModerationActionFilter;
}
export async function retrieveCommentModerationActionConnection(
mongo: Db,
tenantID: string,
input: ConnectionInput
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: ConnectionInput,
query: Query<CommentModerationAction>
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// Apply the cursor to the query. Currently only supporting sorting by the
// newest first.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// We load one more than the limit so we can determine if there is
// another page of entries. This gets trimmed off below after we've checked to
// see if this constitutes another page of edges.
query.first(input.first + 1);
// Get the cursor.
const cursor = await query.exec();
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, a => a.createdAt);
// Get the pageInfo for the connection. We will use this to also determine if
// we need to trim off the extra edge that we requested by comparing its
// hasNextPage parameter.
const pageInfo = getPageInfo(input, edges);
if (pageInfo.hasNextPage) {
// Because this means that we got one more than expected, we should trim off
// the extra edge that was retrieved.
edges.splice(input.first, 1);
}
// Return the connection.
return {
edges,
pageInfo,
};
}
+256 -106
View File
@@ -7,7 +7,7 @@ import {
GQLCOMMENT_SORT,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedActionCounts } from "talk-server/models/action";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import {
Connection,
createConnection,
@@ -23,82 +23,156 @@ function collection(db: Db) {
return db.collection<Readonly<Comment>>("comments");
}
export interface BodyHistoryItem {
body: string;
created_at: Date;
}
export interface StatusHistoryItem {
status: GQLCOMMENT_STATUS;
assigned_by?: string;
created_at: Date;
}
export interface Comment extends TenantResource {
/**
* Revision stores a Comment's body for a specific edit. Actions can be tied to
* a Revision, as can moderation actions.
*/
export interface Revision {
/**
* id identifies this Revision.
*/
readonly id: string;
parent_id?: string;
author_id: string;
story_id: string;
/**
* body is the body text for this revision.
*/
body: string;
body_history: BodyHistoryItem[];
/**
* actionCounts is the cached action counts on this revision.
*/
actionCounts: EncodedCommentActionCounts;
/**
* createdAt is the date that this revision was created at.
*/
createdAt: Date;
}
/**
* Comment's are created by User's on Stories. Each Comment contains a body, and
* can be moderated by another Moderator or Admin User.
*/
export interface Comment extends TenantResource {
/**
* id identifies this Comment specifically.
*/
readonly id: string;
/**
* parentID stores the ID of a parent Comment if this Comment is a reply.
*/
parentID?: string;
/**
* authorID stores the ID of the User that created this Comment.
*/
authorID: string;
/**
* storyID stores the ID of the Story that this Comment was left on.
*/
storyID: string;
/**
* revisions stores all the revisions of the Comment body including the most
* recent revision, the last revision is the most recent.
*/
revisions: Revision[];
/**
* status is the current Comment Status.
*/
status: GQLCOMMENT_STATUS;
status_history: StatusHistoryItem[];
action_counts: EncodedActionCounts;
grandparent_ids: string[];
reply_ids: string[];
reply_count: number;
created_at: Date;
deleted_at?: Date;
/**
* actionCounts stores a cached count of all the Action's against this
* Comment.
*/
actionCounts: EncodedCommentActionCounts;
/**
* grandparentIDs stores all the ID's of all the Comment's that came before.
* This prevents the need for performing multiple queries to retrieve the
* Comment ancestors.
*/
grandparentIDs: string[];
/**
* replyIDs are the ID's of all the Comment's that are direct replies.
*/
replyIDs: string[];
/**
* replyCount is the count of direct replies. It is stored as a separate value
* here even though the replyIDs field technically contained the same data in
* it's length because we needed to sort by this field sometimes.
*/
replyCount: number;
/**
* createdAt is the date that this Comment was created.
*/
createdAt: Date;
/**
* deletedAt is the date that this Comment was deleted on. If null or
* undefined, this Comment is not deleted.
*/
deletedAt?: Date;
/**
* metadata stores the deep Comment properties.
*/
metadata?: Record<string, any>;
}
export type CreateCommentInput = Omit<
Comment,
| "id"
| "tenant_id"
| "created_at"
| "reply_ids"
| "reply_count"
| "body_history"
| "status_history"
>;
| "tenantID"
| "createdAt"
| "replyIDs"
| "replyCount"
| "actionCounts"
| "revisions"
> &
Required<Pick<Revision, "body">>;
export async function createComment(
db: Db,
tenantID: string,
input: CreateCommentInput
) {
const now = new Date();
const createdAt = new Date();
// Pull out some useful properties from the input.
const { body, status } = input;
const { body, ...rest } = input;
// Generate the revision.
const revision: Revision = {
id: uuid.v4(),
body,
actionCounts: {},
createdAt,
};
// default are the properties set by the application when a new comment is
// created.
const defaults: Sub<Comment, CreateCommentInput> = {
id: uuid.v4(),
tenant_id: tenantID,
created_at: now,
reply_ids: [],
reply_count: 0,
body_history: [
{
body,
created_at: now,
},
],
status_history: [
{
status,
created_at: now,
},
],
tenantID,
createdAt,
replyIDs: [],
replyCount: 0,
actionCounts: {},
revisions: [revision],
};
// Merge the defaults and the input together.
const comment: Readonly<Comment> = {
...defaults,
...input,
...rest,
};
// Insert it into the database.
@@ -120,12 +194,12 @@ export async function pushChildCommentIDOntoParent(
// This pushes the new child ID onto the parent comment.
const result = await collection(mongo).findOneAndUpdate(
{
tenant_id: tenantID,
tenantID,
id: parentID,
},
{
$push: { reply_ids: childID },
$inc: { reply_count: 1 },
$push: { replyIDs: childID },
$inc: { replyCount: 1 },
}
);
@@ -134,7 +208,7 @@ export async function pushChildCommentIDOntoParent(
export type EditCommentInput = Pick<
Comment,
"id" | "author_id" | "body" | "status" | "metadata"
"id" | "authorID" | "status" | "metadata"
> & {
/**
* lastEditableCommentCreatedAt is the date that the last comment would have
@@ -142,18 +216,22 @@ export type EditCommentInput = Pick<
* `editCommentWindowLength` property.
*/
lastEditableCommentCreatedAt: Date;
};
} & Required<Pick<Revision, "body">>;
export async function editComment(
db: Db,
tenantID: string,
input: EditCommentInput
) {
// TODO: (wyattjoh) now that we have revisions, do we really have this restriction?
// Only comments with the following status's can be edited.
const EDITABLE_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.ACCEPTED,
];
const createdAt = new Date();
const {
@@ -161,28 +239,33 @@ export async function editComment(
body,
lastEditableCommentCreatedAt,
status,
author_id,
authorID,
metadata,
} = input;
// TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment
// Generate the revision.
const revision: Revision = {
id: uuid.v4(),
body,
actionCounts: {},
createdAt,
};
const result = await collection(db).findOneAndUpdate(
{
id,
tenant_id: tenantID,
author_id,
tenantID,
authorID,
status: {
$in: EDITABLE_STATUSES,
},
deleted_at: null,
created_at: {
deletedAt: null,
createdAt: {
$gt: lastEditableCommentCreatedAt,
},
},
{
$set: {
body,
status,
// Embed all the metadata properties, this may override the existing
// metadata, but we won't replace metadata that has been recalculated.
@@ -190,14 +273,7 @@ export async function editComment(
...dotize({ metadata }),
},
$push: {
body_history: {
body,
created_at: createdAt,
},
status_history: {
type: status,
created_at: createdAt,
},
revisions: revision,
},
},
// False to return the updated document instead of the original
@@ -212,7 +288,7 @@ export async function editComment(
throw new Error("comment not found");
}
if (comment.author_id !== author_id) {
if (comment.authorID !== authorID) {
// TODO: (wyattjoh) return better error
throw new Error("comment author mismatch");
}
@@ -224,7 +300,7 @@ export async function editComment(
}
// Check to see if the edit window expired.
if (comment.created_at <= lastEditableCommentCreatedAt) {
if (comment.createdAt <= lastEditableCommentCreatedAt) {
// TODO: (wyattjoh) return better error
throw new Error("edit window expired");
}
@@ -237,7 +313,7 @@ export async function editComment(
}
export async function retrieveComment(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
return collection(db).findOne({ id, tenantID });
}
export async function retrieveManyComments(
@@ -249,7 +325,7 @@ export async function retrieveManyComments(
id: {
$in: ids,
},
tenant_id: tenantID,
tenantID,
});
const comments = await cursor.toArray();
@@ -269,7 +345,7 @@ function cursorGetterFactory(
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
case GQLCOMMENT_SORT.CREATED_AT_ASC:
return comment => comment.created_at;
return comment => comment.createdAt;
case GQLCOMMENT_SORT.REPLIES_DESC:
case GQLCOMMENT_SORT.RESPECT_DESC:
return (_, index) =>
@@ -294,9 +370,9 @@ export async function retrieveCommentRepliesConnection(
) {
// Create the query.
const query = new Query(collection(db)).where({
tenant_id: tenantID,
story_id: storyID,
parent_id: parentID,
tenantID,
storyID,
parentID,
});
// Return a connection for the comments query.
@@ -319,7 +395,7 @@ export async function retrieveCommentParentsConnection(
{ last: limit, before: skip = 0 }: { last: number; before?: number }
): Promise<Readonly<Connection<Readonly<Comment>>>> {
// Return nothing if this comment does not have any parents.
if (!comment.parent_id) {
if (!comment.parentID) {
return createConnection({
pageInfo: {
hasNextPage: false,
@@ -334,7 +410,7 @@ export async function retrieveCommentParentsConnection(
return createConnection({
pageInfo: {
hasNextPage: false,
hasPreviousPage: !!comment.parent_id,
hasPreviousPage: !!comment.parentID,
endCursor: 0,
startCursor: 0,
},
@@ -344,7 +420,7 @@ export async function retrieveCommentParentsConnection(
// If the last paramter is 1, and the after paramter is either unset or equal
// to zero, then all we have to return is the direct parent.
if (limit === 1 && skip <= 0) {
const parent = await retrieveComment(mongo, tenantID, comment.parent_id);
const parent = await retrieveComment(mongo, tenantID, comment.parentID);
if (!parent) {
throw new Error("parent comment not found");
}
@@ -353,7 +429,7 @@ export async function retrieveCommentParentsConnection(
edges: [{ node: parent, cursor: 1 }],
pageInfo: {
hasNextPage: false,
hasPreviousPage: comment.grandparent_ids.length > 0,
hasPreviousPage: comment.grandparentIDs.length > 0,
endCursor: 1,
startCursor: 1,
},
@@ -361,7 +437,7 @@ export async function retrieveCommentParentsConnection(
}
// Create a list of all the comment parent ids, in reverse order.
const parentIDs = [comment.parent_id, ...comment.grandparent_ids.reverse()];
const parentIDs = [comment.parentID, ...comment.grandparentIDs.reverse()];
// Fetch the subset of the comment id's that we are going to query for.
const parentIDSubset = parentIDs.slice(skip, skip + limit);
@@ -415,9 +491,15 @@ export async function retrieveCommentStoryConnection(
) {
// Create the query.
const query = new Query(collection(db)).where({
tenant_id: tenantID,
story_id: storyID,
parent_id: null,
tenantID,
storyID,
// Only get Comments that are top level. If the client wants to load another
// layer, they can request another nested connection.
parentID: null,
// Only get Comment's that are visible.
status: {
$in: [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.ACCEPTED],
},
});
// Return a connection for the comments query.
@@ -440,8 +522,8 @@ export async function retrieveCommentUserConnection(
) {
// Create the query.
const query = new Query(collection(db)).where({
tenant_id: tenantID,
author_id: userID,
tenantID,
authorID: userID,
});
// Return a connection for the comments query.
@@ -498,25 +580,25 @@ async function retrieveConnection(
function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
query.orderBy({ created_at: -1 });
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ created_at: { $lt: input.after as Date } });
query.where({ createdAt: { $lt: input.after as Date } });
}
break;
case GQLCOMMENT_SORT.CREATED_AT_ASC:
query.orderBy({ created_at: 1 });
query.orderBy({ createdAt: 1 });
if (input.after) {
query.where({ created_at: { $gt: input.after as Date } });
query.where({ createdAt: { $gt: input.after as Date } });
}
break;
case GQLCOMMENT_SORT.REPLIES_DESC:
query.orderBy({ reply_count: -1, created_at: -1 });
query.orderBy({ replyCount: -1, createdAt: -1 });
if (input.after) {
query.after(input.after as number);
}
break;
case GQLCOMMENT_SORT.RESPECT_DESC:
query.orderBy({ "action_counts.REACTION": -1, created_at: -1 });
query.orderBy({ "actionCounts.REACTION": -1, createdAt: -1 });
if (input.after) {
query.after(input.after as number);
}
@@ -524,6 +606,52 @@ function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
}
}
export interface UpdateCommentStatus {
comment: Readonly<Comment>;
oldStatus: GQLCOMMENT_STATUS;
}
export async function updateCommentStatus(
mongo: Db,
tenantID: string,
id: string,
revisionID: string,
status: GQLCOMMENT_STATUS
): Promise<UpdateCommentStatus | null> {
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"revisions.id": revisionID,
status: {
$ne: status,
},
},
{
$set: { status },
},
{
// True to return the original document instead of the updated
// document.
returnOriginal: true,
}
);
if (!result.value) {
return null;
}
// Grab the old status.
const oldStatus = result.value.status;
return {
comment: {
...result.value,
status,
},
oldStatus,
};
}
/**
* updateCommentActionCounts will update the given comment's action counts.
*
@@ -536,19 +664,30 @@ export async function updateCommentActionCounts(
mongo: Db,
tenantID: string,
id: string,
actionCounts: EncodedActionCounts
revisionID: string,
actionCounts: EncodedCommentActionCounts
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenant_id: tenantID },
{ id, tenantID },
// Update all the specific action counts that are associated with each of
// the counts.
{ $inc: dotize({ action_counts: actionCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
{
$inc: dotize({
actionCounts,
"revisions.$[revision]": { actionCounts },
}),
},
{
// Add an ArrayFilter to only update one of the OpenID Connect
// integrations.
arrayFilters: [{ "revision.id": revisionID }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value;
return result.value || null;
}
/**
@@ -562,8 +701,8 @@ export async function removeStoryComments(
) {
// Delete all the comments written on a specific story.
return collection(mongo).deleteMany({
tenant_id: tenantID,
story_id: storyID,
tenantID,
storyID,
});
}
@@ -578,15 +717,26 @@ export async function mergeManyCommentStories(
) {
return collection(mongo).updateMany(
{
tenant_id: tenantID,
story_id: {
tenantID,
storyID: {
$in: oldStoryIDs,
},
},
{
$set: {
story_id: newStoryID,
storyID: newStoryID,
},
}
);
}
/**
* getLatestRevision will get the latest revision from a Comment.
*
* @param comment the comment that contains the revisions
*/
export function getLatestRevision(
comment: Pick<Comment, "revisions">
): Revision {
return comment.revisions[comment.revisions.length - 1];
}
+31 -31
View File
@@ -7,7 +7,7 @@ import {
GQLCOMMENT_STATUS,
GQLStoryMetadata,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedActionCounts } from "talk-server/models/action";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
@@ -43,15 +43,15 @@ export interface Story extends TenantResource {
scrapedAt?: Date;
/**
* action_counts stores all the action counts for all Comment's on this Story.
* actionCounts stores all the action counts for all Comment's on this Story.
*/
action_counts: EncodedActionCounts;
commentActionCounts: EncodedCommentActionCounts;
/**
* comment_counts stores the different counts for each comment on the Story
* commentCounts stores the different counts for each comment on the Story
* according to their statuses.
*/
comment_counts: CommentStatusCounts;
commentCounts: CommentStatusCounts;
/**
* settings provides a point where the settings can be overridden for a
@@ -66,9 +66,9 @@ export interface Story extends TenantResource {
closedAt?: Date | false;
/**
* created_at is the date that the Story was added to the Talk database.
* createdAt is the date that the Story was added to the Talk database.
*/
created_at: Date;
createdAt: Date;
}
export interface UpsertStoryInput {
@@ -84,15 +84,15 @@ export async function upsertStory(
const now = new Date();
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenant_id.
// porting in the tenantID.
const update: { $setOnInsert: Story } = {
$setOnInsert: {
id: id ? id : uuid.v4(),
url,
tenant_id: tenantID,
created_at: now,
action_counts: {},
comment_counts: createEmptyCommentCounts(),
tenantID,
createdAt: now,
commentActionCounts: {},
commentCounts: createEmptyCommentCounts(),
},
};
@@ -101,7 +101,7 @@ export async function upsertStory(
const result = await collection(db).findOneAndUpdate(
{
url,
tenant_id: tenantID,
tenantID,
},
update,
{
@@ -136,11 +136,11 @@ export async function updateCommentStatusCount(
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenant_id: tenantID,
tenantID,
},
// Update all the specific comment status counts that are associated with
// each of the counts.
{ $inc: dotize({ comment_counts: commentStatusCounts }) },
{ $inc: dotize({ commentCounts: commentStatusCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
@@ -238,10 +238,10 @@ export async function createStory(
...input,
id,
url,
tenant_id: tenantID,
created_at: now,
action_counts: {},
comment_counts: createEmptyCommentCounts(),
tenantID,
createdAt: now,
commentActionCounts: {},
commentCounts: createEmptyCommentCounts(),
};
try {
@@ -267,11 +267,11 @@ export async function retrieveStoryByURL(
tenantID: string,
url: string
) {
return collection(db).findOne({ url, tenant_id: tenantID });
return collection(db).findOne({ url, tenantID });
}
export async function retrieveStory(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
return collection(db).findOne({ id, tenantID });
}
export async function retrieveManyStories(
@@ -281,7 +281,7 @@ export async function retrieveManyStories(
) {
const cursor = await collection(db).find({
id: { $in: ids },
tenant_id: tenantID,
tenantID,
});
const stories = await cursor.toArray();
@@ -296,7 +296,7 @@ export async function retrieveManyStoriesByURL(
) {
const cursor = await collection(db).find({
url: { $in: urls },
tenant_id: tenantID,
tenantID,
});
const stories = await cursor.toArray();
@@ -306,7 +306,7 @@ export async function retrieveManyStoriesByURL(
export type UpdateStoryInput = Omit<
Partial<Story>,
"id" | "tenant_id" | "created_at"
"id" | "tenantID" | "createdAt"
>;
export async function updateStory(
@@ -320,13 +320,13 @@ export async function updateStory(
$set: {
...dotize(input, { embedArrays: true }),
// Always update the updated at time.
updated_at: new Date(),
updatedAt: new Date(),
},
};
try {
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
{ id, tenantID },
update,
// False to return the updated document instead of the original
// document.
@@ -359,13 +359,13 @@ export async function updateStoryActionCounts(
mongo: Db,
tenantID: string,
id: string,
actionCounts: EncodedActionCounts
actionCounts: EncodedCommentActionCounts
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenant_id: tenantID },
{ id, tenantID },
// Update all the specific action counts that are associated with each of
// the counts.
{ $inc: dotize({ action_counts: actionCounts }) },
{ $inc: dotize({ actionCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
@@ -377,7 +377,7 @@ export async function updateStoryActionCounts(
export async function removeStory(mongo: Db, tenantID: string, id: string) {
const result = await collection(mongo).findOneAndDelete({
id,
tenant_id: tenantID,
tenantID,
});
return result.value || null;
@@ -392,7 +392,7 @@ export async function removeStories(
ids: string[]
) {
return collection(mongo).deleteMany({
tenant_id: tenantID,
tenantID,
id: {
$in: ids,
},
+5 -1
View File
@@ -20,7 +20,11 @@ function dotizeDropNull(o: Record<string, any>, options?: DotizeOptions) {
}
export interface TenantResource {
readonly tenant_id: string;
/**
* tenantID is the reference to the specific Tenant that owns this particular
* resource.
*/
readonly tenantID: string;
}
/**
+13 -22
View File
@@ -7,7 +7,6 @@ import {
GQLUSER_ROLE,
GQLUSER_USERNAME_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedActionCounts } from "talk-server/models/action";
import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
@@ -57,9 +56,9 @@ export interface Token {
export interface UserStatusHistory<T> {
status: T;
assigned_by?: string;
assignedBy?: string;
reason?: string;
created_at: Date;
createdAt: Date;
}
export interface UserStatusItem<T> {
@@ -80,25 +79,18 @@ export interface User extends TenantResource {
password?: string;
avatar?: string;
email?: string;
email_verified?: boolean;
emailVerified?: boolean;
profiles: Profile[];
tokens: Token[];
role: GQLUSER_ROLE;
status: UserStatus;
action_counts: EncodedActionCounts;
ignored_users: string[];
created_at: Date;
ignoredUserIDs: string[];
createdAt: Date;
}
export type UpsertUserInput = Omit<
User,
| "id"
| "tenant_id"
| "tokens"
| "status"
| "action_counts"
| "ignored_users"
| "created_at"
"id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt"
>;
export async function upsertUser(
@@ -115,10 +107,9 @@ export async function upsertUser(
// created.
const defaults: Sub<User, UpsertUserInput> = {
id,
tenant_id: tenantID,
tenantID,
tokens: [],
action_counts: {},
ignored_users: [],
ignoredUserIDs: [],
status: {
banned: {
status: false,
@@ -135,7 +126,7 @@ export async function upsertUser(
history: [],
},
},
created_at: now,
createdAt: now,
};
let hashedPassword;
@@ -202,7 +193,7 @@ const createUpsertUserFilter = (user: Readonly<User>) => {
};
export async function retrieveUser(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
return collection(db).findOne({ id, tenantID });
}
export async function retrieveManyUsers(
@@ -214,7 +205,7 @@ export async function retrieveManyUsers(
id: {
$in: ids,
},
tenant_id: tenantID,
tenantID,
});
const users = await cursor.toArray();
@@ -228,7 +219,7 @@ export async function retrieveUserWithProfile(
profile: Profile
) {
return collection(db).findOne({
tenant_id: tenantID,
tenantID,
profiles: {
$elemMatch: profile,
},
@@ -242,7 +233,7 @@ export async function updateUserRole(
role: GQLUSER_ROLE
) {
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
{ id, tenantID },
{ $set: { role } },
{ returnOriginal: false }
);
+11 -16
View File
@@ -1,4 +1,6 @@
import Queue, { Job, Queue as QueueType } from "bull";
import Logger from "bunyan";
import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
@@ -10,9 +12,12 @@ export interface TaskOptions<T, U = any> {
export default class Task<T, U = any> {
private options: TaskOptions<T, U>;
private queue: QueueType<T>;
private log: Logger;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
this.log = logger.child({ jobName: options.jobName });
// Sets up and attaches the job processor to the queue.
this.setupAndAttachProcessor();
@@ -31,31 +36,21 @@ export default class Task<T, U = any> {
removeOnComplete: true,
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"added job to queue"
);
this.log.trace({ jobID: job.id }, "added job to queue");
return job;
}
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing job from queue"
);
const log = this.log.child({ jobID: job.id });
log.trace("processing job from queue");
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing completed"
);
log.trace("processing completed");
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
"registered processor for job type"
);
this.log.trace("registered processor for job type");
}
}
+4 -4
View File
@@ -33,7 +33,7 @@ export default class Task<T, U = any> {
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
{ jobID: job.id, jobName: this.options.jobName },
"added job to queue"
);
return job;
@@ -42,21 +42,21 @@ export default class Task<T, U = any> {
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
{ jobID: job.id, jobName: this.options.jobName },
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
{ jobID: job.id, jobName: this.options.jobName },
"processing completed"
);
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
{ jobName: this.options.jobName },
"registered processor for job type"
);
}
+23 -78
View File
@@ -55,8 +55,8 @@ const createJobProcessor = (options: MailProcessorOptions) => {
if (err) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
jobID: job.id,
jobName: JOB_NAME,
err,
},
"job data did not match expected schema"
@@ -67,52 +67,32 @@ const createJobProcessor = (options: MailProcessorOptions) => {
// Pull the data out of the validated model.
const { message, tenantID } = value;
const log = logger.child({
jobID: job.id,
jobName: JOB_NAME,
tenantID,
});
// Get the referenced tenant so we know who to send it from.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
log.error("referenced tenant was not found");
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not sending email, it was disabled"
);
log.error("not sending email, it was disabled");
return;
}
if (!tenant.email.smtpURI) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"email was enabled but the smtpURI configuration was missing"
);
log.error("email was enabled but the smtpURI configuration was missing");
return;
}
if (!tenant.email.fromAddress) {
// TODO: possibly have fallback email address?
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
log.error(
"email was enabled but the fromAddress configuration was missing"
);
return;
@@ -126,33 +106,12 @@ const createJobProcessor = (options: MailProcessorOptions) => {
// Set the transport back into the cache.
cache.set(tenantID, transport);
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was not cached"
);
log.debug("transport was not cached");
} else {
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was cached"
);
log.debug("transport was cached");
}
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"starting to send the email"
);
log.debug("starting to send the email");
// Send the mail message.
await transport.sendMail({
@@ -162,14 +121,7 @@ const createJobProcessor = (options: MailProcessorOptions) => {
from: tenant.email.fromAddress,
});
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"sent the email"
);
log.debug("sent the email");
};
};
@@ -203,29 +155,22 @@ export class Mailer {
public async add({ template, ...rest }: MailerInput) {
const { tenantID } = rest;
const log = logger.child({
jobName: JOB_NAME,
tenantID,
});
// All email templates require the tenant in order to insert the footer, so
// load it from the tenant cache here.
const tenant = await this.tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
log.error("referenced tenant was not found");
// TODO: (wyattjoh) maybe throw an error here?
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not adding email, it was disabled"
);
log.error("not adding email, it was disabled");
// TODO: (wyattjoh) maybe throw an error here?
return;
}
+5 -5
View File
@@ -24,11 +24,11 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
const { storyID, storyURL, tenantID } = job.data;
const log = logger.child({
job_id: job.id,
job_name: JOB_NAME,
story_id: storyID,
story_url: storyURL,
tenant_id: tenantID,
jobID: job.id,
jobName: JOB_NAME,
storyID,
storyURL,
tenantID,
});
log.debug("starting to scrape the story");
+74 -43
View File
@@ -3,7 +3,6 @@ import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
ACTION_TYPE,
CreateActionInput,
createActions,
@@ -11,8 +10,10 @@ import {
invertEncodedActionCounts,
removeAction,
RemoveActionInput,
} from "talk-server/models/action";
retrieveUserAction,
} from "talk-server/models/action/comment";
import {
getLatestRevision,
retrieveComment,
updateCommentActionCounts,
} from "talk-server/models/comment";
@@ -21,8 +22,8 @@ import { updateStoryActionCounts } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
export type CreateAction = Omit<CreateActionInput, "root_item_id"> &
Required<Pick<CreateActionInput, "root_item_id">>;
export type CreateAction = Omit<CreateActionInput, "storyID"> &
Required<Pick<CreateActionInput, "storyID">>;
export async function addCommentActions(
mongo: Db,
@@ -43,11 +44,15 @@ export async function addCommentActions(
// Compute the action counts.
const actionCounts = encodeActionCounts(...upsertedActions);
// Grab the last revision (the most recent).
const revision = getLatestRevision(comment);
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
revision.id,
actionCounts
);
@@ -55,7 +60,7 @@ export async function addCommentActions(
await updateStoryActionCounts(
mongo,
tenant.id,
comment.story_id,
comment.storyID,
actionCounts
);
@@ -77,17 +82,17 @@ async function addCommentAction(
tenant: Tenant,
input: CreateActionInput
): Promise<Readonly<Comment>> {
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// Store the story ID on the action as a story_id.
input.root_item_id = comment.story_id;
input.storyID = comment.storyID;
// We have to perform a type assertion here because for some reason, the type
// coercion is not determining that because we filled in the `root_item_id`
// coercion is not determining that because we filled in the `storyID`
// above, that at this point, it satisfies the CreateAction type.
return addCommentActions(mongo, tenant, comment, [input as CreateAction]);
}
@@ -95,17 +100,36 @@ async function addCommentAction(
export async function removeCommentAction(
mongo: Db,
tenant: Tenant,
input: RemoveActionInput
input: Omit<RemoveActionInput, "commentRevisionID">
): Promise<Readonly<Comment>> {
// Get the Comment that we are leaving the Action on.
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// Get the revision for the specific action being removed.
const action = await retrieveUserAction(
mongo,
tenant.id,
input.userID,
input.commentID,
input.actionType
);
if (!action) {
// The action that is trying to get removed does not exist!
return comment;
}
// Grab the revision ID out of the action.
const { commentID, commentRevisionID } = action;
// Create each of the actions, returning each of the action results.
const { wasRemoved, action } = await removeAction(mongo, tenant.id, input);
const { wasRemoved } = await removeAction(mongo, tenant.id, {
...input,
commentRevisionID,
});
if (wasRemoved) {
// Compute the action counts, and invert them (because we're deleting an
// action).
@@ -115,7 +139,8 @@ export async function removeCommentAction(
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
commentID,
commentRevisionID,
actionCounts
);
@@ -123,7 +148,7 @@ export async function removeCommentAction(
await updateStoryActionCounts(
mongo,
tenant.id,
comment.story_id,
comment.storyID,
actionCounts
);
@@ -139,7 +164,10 @@ export async function removeCommentAction(
return comment;
}
export type CreateCommentReaction = Pick<CreateActionInput, "item_id">;
export type CreateCommentReaction = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export async function createReaction(
mongo: Db,
@@ -148,14 +176,14 @@ export async function createReaction(
input: CreateCommentReaction
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentReaction = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
export async function removeReaction(
mongo: Db,
@@ -164,14 +192,16 @@ export async function removeReaction(
input: RemoveCommentReaction
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
userID: author.id,
});
}
export type CreateCommentDontAgree = Pick<CreateActionInput, "item_id">;
export type CreateCommentDontAgree = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export async function createDontAgree(
mongo: Db,
@@ -180,14 +210,14 @@ export async function createDontAgree(
input: CreateCommentDontAgree
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
export async function removeDontAgree(
mongo: Db,
@@ -196,14 +226,16 @@ export async function removeDontAgree(
input: RemoveCommentDontAgree
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
userID: author.id,
});
}
export type CreateCommentFlag = Pick<CreateActionInput, "item_id"> & {
export type CreateCommentFlag = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
> & {
reason: GQLCOMMENT_FLAG_REPORTED_REASON;
};
@@ -214,15 +246,15 @@ export async function createFlag(
input: CreateCommentFlag
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: input.reason,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentFlag = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentFlag = Pick<RemoveActionInput, "commentID">;
export async function removeFlag(
mongo: Db,
@@ -231,9 +263,8 @@ export async function removeFlag(
input: RemoveCommentFlag
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.FLAG,
commentID: input.commentID,
userID: author.id,
});
}
+22 -23
View File
@@ -1,12 +1,12 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { ACTION_ITEM_TYPE } from "talk-server/models/action";
import {
createComment,
CreateCommentInput,
editComment,
EditCommentInput,
getLatestRevision,
pushChildCommentIDOntoParent,
retrieveComment,
} from "talk-server/models/comment";
@@ -25,7 +25,7 @@ import { Request } from "talk-server/types/express";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "action_counts" | "metadata" | "grandparent_ids"
"status" | "metadata" | "grandparentIDs"
>;
export async function create(
@@ -36,7 +36,7 @@ export async function create(
req?: Request
) {
// Grab the story that we'll use to check moderation pieces with.
const story = await retrieveStory(mongo, tenant.id, input.story_id);
const story = await retrieveStory(mongo, tenant.id, input.storyID);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
@@ -45,9 +45,9 @@ export async function create(
// TODO: (wyattjoh) Check that the story was visible.
const grandparentIDs: string[] = [];
if (input.parent_id) {
if (input.parentID) {
// Check to see that the reference parent ID exists.
const parent = await retrieveComment(mongo, tenant.id, input.parent_id);
const parent = await retrieveComment(mongo, tenant.id, input.parentID);
if (!parent) {
// TODO: (wyattjoh) return better error.
throw new Error("parent comment referenced does not exist");
@@ -56,10 +56,10 @@ export async function create(
// TODO: (wyattjoh) Check that the parent comment was visible.
// Push the parent's parent id's into the comment's grandparent id's.
grandparentIDs.push(...parent.grandparent_ids);
if (parent.parent_id) {
grandparentIDs.push(...parent.grandparentIDs);
if (parent.parentID) {
// If this parent has a parent, push it down as well.
grandparentIDs.push(parent.parent_id);
grandparentIDs.push(parent.parentID);
}
}
@@ -76,23 +76,22 @@ export async function create(
let comment = await createComment(mongo, tenant.id, {
...input,
status,
action_counts: {},
grandparent_ids: grandparentIDs,
grandparentIDs,
metadata,
});
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// The actions coming from the moderation phases didn't know the commentID
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateAction => ({
...action,
item_id: comment.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
commentID: comment.id,
commentRevisionID: getLatestRevision(comment!).id,
// Store the Story ID on the action.
root_item_id: story.id,
storyID: story.id,
})
);
@@ -100,12 +99,12 @@ export async function create(
comment = await addCommentActions(mongo, tenant, comment, inputs);
}
if (input.parent_id) {
if (input.parentID) {
// Push the child's ID onto the parent.
await pushChildCommentIDOntoParent(
mongo,
tenant.id,
input.parent_id,
input.parentID,
comment.id
);
}
@@ -120,7 +119,7 @@ export async function create(
export type EditComment = Omit<
EditCommentInput,
"status" | "author_id" | "lastEditableCommentCreatedAt"
"status" | "authorID" | "lastEditableCommentCreatedAt"
>;
export async function edit(
@@ -138,7 +137,7 @@ export async function edit(
}
// Grab the story that we'll use to check moderation pieces with.
const story = await retrieveStory(mongo, tenant.id, comment.story_id);
const story = await retrieveStory(mongo, tenant.id, comment.storyID);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
@@ -155,7 +154,7 @@ export async function edit(
let editedComment = await editComment(mongo, tenant.id, {
id: input.id,
author_id: author.id,
authorID: author.id,
body: input.body,
status,
metadata,
@@ -173,7 +172,7 @@ export async function edit(
}
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// The actions coming from the moderation phases didn't know the commentID
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
@@ -181,11 +180,11 @@ export async function edit(
...action,
// Strict null check seems to have failed here... Null checking was done
// above where we errored if the comment was falsely.
item_id: comment!.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
commentID: comment!.id,
commentRevisionID: getLatestRevision(comment!).id,
// Store the Story ID on the action.
root_item_id: story.id,
storyID: story.id,
})
);
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
compose,
ModerationPhaseContext,
@@ -51,11 +51,13 @@ describe("compose", () => {
const flags = [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
];
@@ -71,7 +73,8 @@ describe("compose", () => {
() => ({
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
],
@@ -85,7 +88,7 @@ describe("compose", () => {
}
expect(final.actions).not.toContainEqual({
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
});
});
@@ -1,7 +1,7 @@
import { Omit, Promiseable } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { CreateActionInput } from "talk-server/models/action";
import { Comment } from "talk-server/models/comment";
import { CreateActionInput } from "talk-server/models/action/comment";
import { EditCommentInput } from "talk-server/models/comment";
import { Story } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
@@ -9,7 +9,10 @@ import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
export type ModerationAction = Omit<CreateActionInput, "item_id" | "item_type">;
export type ModerationAction = Omit<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export interface PhaseResult {
actions: ModerationAction[];
@@ -20,7 +23,7 @@ export interface PhaseResult {
export interface ModerationPhaseContext {
story: Story;
tenant: Tenant;
comment: Partial<Comment>;
comment: Partial<EditCommentInput>;
author: User;
req?: Request;
}
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -50,7 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
metadata: {
count: length,
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -33,7 +33,8 @@ export const karma: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
metadata: {
trust: getCommentTrustScore(author),
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -39,7 +39,8 @@ export const links: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
metadata: {
links: comment.body,
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -20,29 +20,31 @@ export const spam: IntermediateModerationPhase = async ({
}): Promise<IntermediatePhaseResult | void> => {
const integration = tenant.integrations.akismet;
const log = logger.child({
tenantID: tenant.id,
});
// We can only check for spam if this comment originated from a graphql
// request via an HTTP call.
if (!req) {
logger.debug({ tenant_id: tenant.id }, "request was not available");
log.debug("request was not available");
return;
}
if (!integration.enabled) {
logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled");
log.debug("akismet integration was disabled");
return;
}
if (!integration.key) {
logger.error(
{ tenant_id: tenant.id },
log.error(
"akismet integration was enabled but the key configuration was missing"
);
return;
}
if (!integration.site) {
logger.error(
{ tenant_id: tenant.id },
log.error(
"akismet integration was enabled but the site configuration was missing"
);
return;
@@ -62,33 +64,24 @@ export const spam: IntermediateModerationPhase = async ({
// Grab the properties we need.
const userIP = req.ip;
if (!userIP) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain ip address, aborting spam check"
);
log.debug("request did not contain ip address, aborting spam check");
return;
}
const userAgent = req.get("User-Agent");
if (!userAgent || userAgent.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain User-Agent header, aborting spam check"
);
log.debug("request did not contain User-Agent header, aborting spam check");
return;
}
const referrer = req.get("Referrer");
if (!referrer || referrer.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain Referrer header, aborting spam check"
);
log.debug("request did not contain Referrer header, aborting spam check");
return;
}
try {
logger.trace({ tenant_id: tenant.id }, "checking comment for spam");
log.trace("checking comment for spam");
// Check the comment for spam.
const isSpam = await client.checkSpam({
@@ -102,15 +95,13 @@ export const spam: IntermediateModerationPhase = async ({
is_test: false,
});
if (isSpam) {
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment contained spam"
);
log.trace({ isSpam }, "comment contained spam");
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
],
@@ -121,14 +112,8 @@ export const spam: IntermediateModerationPhase = async ({
};
}
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment did not contain spam"
);
log.trace({ isSpam }, "comment did not contain spam");
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine if comment contained spam"
);
log.error({ err }, "could not determine if comment contained spam");
}
};
@@ -26,7 +26,7 @@ describe("storyClosed", () => {
expect(() =>
storyClosed({
story: { created_at: new Date() } as Story,
story: { createdAt: new Date() } as Story,
tenant: { autoCloseStream: true, closedTimeout: -6000 } as Tenant,
comment: {} as Comment,
author: {} as User,
@@ -17,7 +17,7 @@ export const storyClosed: IntermediateModerationPhase = ({
story.closedAt !== false &&
tenant.autoCloseStream &&
tenant.closedTimeout &&
story.created_at.valueOf() + tenant.closedTimeout <= Date.now()
story.createdAt.valueOf() + tenant.closedTimeout <= Date.now()
) {
// TODO: (wyattjoh) return better error.
throw new Error("story is currently closed for commenting");
@@ -9,7 +9,7 @@ import {
GQLPerspectiveExternalIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -23,21 +23,19 @@ export const toxic: IntermediateModerationPhase = async ({
return;
}
const log = logger.child({ tenantID: tenant.id });
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
// The Toxic comment plugin is not enabled.
logger.debug(
{ tenant_id: tenant.id },
"perspective integration was disabled"
);
log.debug("perspective integration was disabled");
return;
}
if (!integration.key) {
// The Toxic comment requires a key in order to communicate with the API.
logger.error(
{ tenant_id: tenant.id },
log.error(
"perspective integration was enabled but the key configuration was missing"
);
return;
@@ -48,8 +46,8 @@ export const toxic: IntermediateModerationPhase = async ({
// TODO: (wyattjoh) replace hardcoded default with config.
endpoint = "https://commentanalyzer.googleapis.com/v1alpha1";
logger.trace(
{ tenant_id: tenant.id, endpoint },
log.trace(
{ endpoint },
"endpoint missing in integration settings, using defaults"
);
}
@@ -59,8 +57,8 @@ export const toxic: IntermediateModerationPhase = async ({
// TODO: (wyattjoh) replace hardcoded default with config.
threshold = 0.8;
logger.trace(
{ tenant_id: tenant.id, threshold },
log.trace(
{ threshold },
"threshold missing in integration settings, using defaults"
);
}
@@ -69,8 +67,8 @@ export const toxic: IntermediateModerationPhase = async ({
if (isNil(doNotStore)) {
doNotStore = true;
logger.trace(
{ tenant_id: tenant.id, do_not_store: doNotStore },
log.trace(
{ doNotStore },
"doNotStore missing in integration settings, using defaults"
);
}
@@ -79,7 +77,7 @@ export const toxic: IntermediateModerationPhase = async ({
const timeout = ms("300ms");
try {
logger.trace({ tenant_id: tenant.id }, "checking comment toxicity");
logger.trace("checking comment toxicity");
// Call into the Toxic comment API.
const scores = await getScores(
@@ -95,15 +93,13 @@ export const toxic: IntermediateModerationPhase = async ({
const score = scores.SEVERE_TOXICITY.summaryScore;
const isToxic = score > threshold;
if (isToxic) {
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was toxic"
);
log.trace({ score, isToxic, threshold }, "comment was toxic");
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
],
@@ -114,15 +110,9 @@ export const toxic: IntermediateModerationPhase = async ({
};
}
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was not toxic"
);
log.trace({ score, isToxic, threshold }, "comment was not toxic");
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine comment toxicity"
);
log.error({ err }, "could not determine comment toxicity");
}
};
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -29,7 +29,8 @@ export const wordList: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
],
@@ -46,7 +47,8 @@ export const wordList: IntermediateModerationPhase = ({
return {
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
],
@@ -1,8 +1,7 @@
import { memoize } from "lodash";
// TODO: reintroduce this when we have https://github.com/DefinitelyTyped/DefinitelyTyped/pull/30035 merged
// // Replace `memoize.Cache`.
// memoize.Cache = WeakMap;
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
/**
* Escape string for special regular expression characters.
@@ -0,0 +1,81 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import {
createCommentModerationAction,
CreateCommentModerationActionInput,
} from "talk-server/models/action/moderation/comment";
import { updateCommentStatus } from "talk-server/models/comment";
import { updateCommentStatusCount } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
export type Moderate = Omit<CreateCommentModerationActionInput, "status">;
const moderate = (status: GQLCOMMENT_STATUS) => async (
mongo: Db,
tenant: Tenant,
input: Moderate
) => {
// TODO: wrap these operations in a transaction?
// Create the logger.
const log = logger.child({
...input,
tenantID: tenant.id,
newStatus: status,
});
// Update the Comment's status.
const result = await updateCommentStatus(
mongo,
tenant.id,
input.commentID,
input.commentRevisionID,
status
);
if (!result) {
// TODO: wrap in better error?
throw new Error("specified comment not found");
}
log.trace("updated comment status");
// Create the moderation action in the audit log.
const action = await createCommentModerationAction(mongo, tenant.id, {
...input,
status,
});
if (!action) {
// TODO: wrap in better error?
throw new Error("could not create moderation action");
}
log.trace(
{ commentModerationActionID: action.id },
"created the moderation action"
);
// Update the story comment counts.
const story = await updateCommentStatusCount(
mongo,
tenant.id,
result.comment.storyID,
{
[result.oldStatus]: -1,
[status]: 1,
}
);
if (!story) {
// TODO: wrap in better error?
throw new Error("specified story not found");
}
log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts");
return result.comment;
};
export const accept = moderate(GQLCOMMENT_STATUS.ACCEPTED);
export const reject = moderate(GQLCOMMENT_STATUS.REJECTED);
+31 -45
View File
@@ -10,10 +10,10 @@ import {
import logger from "talk-server/logger";
import {
countTotalActionCounts,
mergeActionCounts,
mergeManyRootActions,
removeRootActions,
} from "talk-server/models/action";
mergeCommentActionCounts,
mergeManyStoryActions,
removeStoryActions,
} from "talk-server/models/action/comment";
import {
mergeManyCommentStories,
removeStoryComments,
@@ -123,8 +123,8 @@ export async function remove(
) {
// Create a logger for this function.
const log = logger.child({
story_id: storyID,
include_comments: includeComments,
storyID,
includeComments,
});
log.debug("starting to remove story");
@@ -138,32 +138,24 @@ export async function remove(
}
if (includeComments) {
let removedCount: number | undefined;
// Remove the actions associated with the comments we just removed.
({ deletedCount: removedCount } = await removeRootActions(
const { deletedCount: removedActions } = await removeStoryActions(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_actions: removedCount },
"removed actions while deleting story"
);
log.debug({ removedActions }, "removed actions while deleting story");
// Remove the comments for the story.
({ deletedCount: removedCount } = await removeStoryComments(
const { deletedCount: removedComments } = await removeStoryComments(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_comments: removedCount },
"removed comments while deleting story"
);
} else if (calculateTotalCommentCount(story.comment_counts) > 0) {
log.debug({ removedComments }, "removed comments while deleting story");
} else if (calculateTotalCommentCount(story.commentCounts) > 0) {
log.warn(
"attempted to remove story that has linked comments without consent for deleting comments"
);
@@ -196,7 +188,7 @@ export async function create(
// Ensure that the given URL is allowed.
if (!isURLPermitted(tenant, storyURL)) {
logger.warn(
{ story_url: storyURL, tenant_domains: tenant.domains },
{ storyURL, tenantDomains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not created"
);
return null;
@@ -224,7 +216,7 @@ export async function update(
// Ensure that the given URL is allowed.
if (input.url && !isURLPermitted(tenant, input.url)) {
logger.warn(
{ story_url: input.url, tenant_domains: tenant.domains },
{ storyURL: input.url, tenantDomains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not updated"
);
return null;
@@ -241,8 +233,8 @@ export async function merge(
) {
// Create a logger for this operation.
const log = logger.child({
destination_id: destinationID,
source_ids: sourceIDs,
destinationID,
sourceIDs,
});
if (sourceIDs.length === 0) {
@@ -259,7 +251,7 @@ export async function merge(
zip(storyIDs, stories).some(([storyID, story]) => {
if (!story) {
log.warn(
{ story_id: storyID },
{ storyID },
"story that was going to be merged was not found"
);
return true;
@@ -271,36 +263,28 @@ export async function merge(
return null;
}
let updatedCount: number | undefined;
// Move all the comment's from the source stories over to the destination
// story.
({ modifiedCount: updatedCount } = await mergeManyCommentStories(
const { modifiedCount: updatedComments } = await mergeManyCommentStories(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_comments: updatedCount },
"updated comments while merging stories"
);
log.debug({ updatedComments }, "updated comments while merging stories");
// Update all the action's that referenced the old story to reference the new
// story.
({ modifiedCount: updatedCount } = await mergeManyRootActions(
const { modifiedCount: updatedActions } = await mergeManyStoryActions(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_actions: updatedCount },
"updated actions while merging stories"
);
log.debug({ updatedActions }, "updated actions while merging stories");
// Merge the comment and action counts for all the source stories.
const [, ...sourceStories] = stories;
@@ -311,14 +295,16 @@ export async function merge(
mergeCommentStatusCount(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ comment_counts }) => comment_counts)
(sourceStories as Story[]).map(({ commentCounts }) => commentCounts)
)
);
const mergedActionCounts = mergeActionCounts(
const mergedActionCounts = mergeCommentActionCounts(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ action_counts }) => action_counts)
(sourceStories as Story[]).map(
({ commentActionCounts }) => commentActionCounts
)
);
if (countTotalActionCounts(mergedActionCounts) > 0) {
destinationStory = await updateStoryActionCounts(
@@ -335,13 +321,13 @@ export async function merge(
}
log.debug(
{ comment_counts: destinationStory.comment_counts },
{ commentCounts: destinationStory.commentCounts },
"updated destination story with new comment counts"
);
const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs);
log.debug({ deleted_stories: deletedCount }, "deleted source stories");
log.debug({ deletedStories: deletedCount }, "deleted source stories");
// Return the story that had the other stories merged into.
return destinationStory;
+2 -2
View File
@@ -195,7 +195,7 @@ export default class TenantCache {
return;
}
logger.debug({ tenant_id: tenant.id }, "received updated tenant");
logger.debug({ tenantID: tenant.id }, "received updated tenant");
// Update the tenant cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
@@ -261,7 +261,7 @@ export default class TenantCache {
JSON.stringify(message)
);
logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant");
logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant");
// Publish the event for the connected listeners.
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
+1 -1
View File
@@ -64,7 +64,7 @@ export async function install(
await cache.update(redis, tenant);
logger.info(
{ tenant_id: tenant.id, tenant_domain: tenant.domain },
{ tenantID: tenant.id, tenantDomain: tenant.domain },
"a tenant has been installed"
);