mirror of
https://github.com/wassname/talk.git
synced 2026-06-28 23:42:44 +08:00
[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:
Generated
+867
-1404
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
+3
-7
@@ -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,
|
||||
}),
|
||||
});
|
||||
+32
-24
@@ -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,
|
||||
}),
|
||||
});
|
||||
+6
-1
@@ -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> =>
|
||||
+17
-7
@@ -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),
|
||||
|
||||
+3
-3
@@ -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),
|
||||
};
|
||||
+3
-3
@@ -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,
|
||||
};
|
||||
+1
-3
@@ -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;
|
||||
+1
-3
@@ -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;
|
||||
+9
-3
@@ -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;
|
||||
+1
-3
@@ -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;
|
||||
+3
-3
@@ -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
-3
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+33
-44
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user