diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 079285b91..5d6d04556 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -19,7 +19,7 @@ export type CreateCommentInput = Omit< const mutation = graphql` mutation CreateCommentMutation($input: CreateCommentInput!) { createComment(input: $input) { - commentEdge { + edge { cursor node { id @@ -51,7 +51,7 @@ function commit(environment: Environment, input: CreateCommentInput) { }, optimisticResponse: { createComment: { - commentEdge: { + edge: { cursor: currentDate, node: { id: uuid(), @@ -77,7 +77,7 @@ function commit(environment: Environment, input: CreateCommentInput) { }, ], parentID: input.assetID, - edgeName: "commentEdge", + edgeName: "edge", }, ], }); diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 9ddd8b1ec..f4d97c398 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -42,7 +42,8 @@ beforeEach(() => { }, }) .returns({ - commentEdge: { + // TODO: add a type assertion here to ensure that if the type changes, that the test will fail + edge: { cursor: "2018-07-06T18:24:00.000Z", node: { id: "comment-x", @@ -95,16 +96,21 @@ it("post a comment", async () => { .findByProps({ inputId: "comments-postCommentForm-field" }) .props.onChange({ html: "Hello world!" }); - timekeeper.travel(new Date("2018-07-06T18:24:00.000Z")); + timekeeper.freeze(new Date("2018-07-06T18:24:00.002Z")); testRenderer.root .findByProps({ id: "comments-postCommentForm-form" }) .props.onSubmit(); + // Test optimistic response. expect(testRenderer.toJSON()).toMatchSnapshot(); - timekeeper.reset(); // Wait for loading. await timeout(); + + // Travel to the time where the "timeout" has executed. + timekeeper.travel(new Date("2018-07-06T18:24:01.002Z")); + // Test after server response. expect(testRenderer.toJSON()).toMatchSnapshot(); + timekeeper.reset(); }); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1560b0095..d0c26e431 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,11 +1,13 @@ import TenantContext from "talk-server/graph/tenant/context"; -import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Comment } from "talk-server/models/comment"; -import { create } from "talk-server/services/comments"; +import { + GQLCreateCommentInput, + GQLEditCommentInput, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { create, edit } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ - create: (input: GQLCreateCommentInput): Promise => { - return create( + create: (input: GQLCreateCommentInput) => + create( ctx.mongo, ctx.tenant, ctx.user!, @@ -16,6 +18,17 @@ export default (ctx: TenantContext) => ({ parent_id: input.parentID, }, ctx.req - ); - }, + ), + edit: (input: GQLEditCommentInput) => + edit( + ctx.mongo, + ctx.tenant, + ctx.user!, + { + id: input.commentID, + asset_id: input.assetID, + body: input.body, + }, + ctx.req + ), }); diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index 3ecf35f41..ee775ff0f 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera import { Comment } from "talk-server/models/comment"; const Comment: GQLCommentTypeResolver = { + 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), diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 1fb331602..8f96079ff 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,11 +1,15 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; const Mutation: GQLMutationTypeResolver = { + editComment: async (source, { input }, ctx) => ({ + comment: await ctx.mutators.Comment.edit(input), + clientMutationId: input.clientMutationId, + }), createComment: async (source, { input }, ctx) => { const comment = await ctx.mutators.Comment.create(input); - // TODO: (cvle) tell wyatt to take a look at this :-) return { - commentEdge: { + edge: { + // FIXME: (wyattjoh) when we're using a replies/respect sort, it is index based instead of date based, needs some work! cursor: comment.created_at, node: comment, }, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9a2dbfae4..79d9a73a8 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -552,6 +552,18 @@ type User { ## Comment ################################################################################ +type EditInfo { + """ + edited will be True when the Comment has been edited in the past. + """ + edited: Boolean! + + """ + editableUntil is the time that the comment is editable until. + """ + editableUntil: Time +} + enum COMMENT_STATUS { """ The comment is not PREMOD, but was not applied a moderation status by a @@ -598,7 +610,7 @@ type Comment { body: String """ - createdAt is the date in which the comment was created. + createdAt is the date in which the Comment was created. """ createdAt: Time! @@ -608,7 +620,7 @@ type Comment { author: User """ - status represents the Comment's current Status. + status represents the Comment's current status. """ status: COMMENT_STATUS! @@ -619,13 +631,18 @@ type Comment { replyCount: Int """ - replies will return the replies to this comment. + replies will return the replies to this Comment. """ replies( first: Int = 10 orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection + + """ + editing returns details about the edit status of a Comment. + """ + editing: EditInfo! } type PageInfo { @@ -844,9 +861,54 @@ mutation. """ type CreateCommentPayload { """ - CommentEdge is the possibly created comment edge. + edge is the possibly created comment edge. """ - commentEdge: CommentEdge + edge: CommentEdge + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +## editComment +################## + +""" +EditCommentInput provides the input for the editComment Mutation. +""" +input EditCommentInput { + """ + assetID is the ID of the Asset where we are editing a comment on. + """ + assetID: ID! + + """ + commentID is the ID of the comment being edited. + """ + commentID: ID! + + """ + body is the Comment body, the content of the Comment. + """ + body: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +""" +EditCommentPayload contains the edited Comment after the editComment +mutation. +""" +type EditCommentPayload { + """ + comment is the possibly edited comment. + """ + comment: Comment """ clientMutationId is required for Relay support. @@ -1231,6 +1293,12 @@ type Mutation { """ createComment(input: CreateCommentInput!): CreateCommentPayload + """ + editComment will allow the author of a comment to change the body within the + time allotment. + """ + editComment(input: EditCommentInput!): EditCommentPayload @auth + """ updateSettings will update the Settings for the given Tenant. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 9a961a17a..d2c8e03a6 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -101,6 +101,97 @@ export async function createComment( return comment; } +export type EditCommentInput = Pick< + Comment, + "id" | "author_id" | "body" | "status" +> & { + /** + * lastEditableCommentCreatedAt is the date that the last comment would have + * been editable. It is generally derived from the tenant's + * `editCommentWindowLength` property. + */ + lastEditableCommentCreatedAt: Date; +}; + +export async function editComment( + db: Db, + tenantID: string, + input: EditCommentInput +) { + const EDITABLE_STATUSES = [ + GQLCOMMENT_STATUS.NONE, + GQLCOMMENT_STATUS.PREMOD, + GQLCOMMENT_STATUS.ACCEPTED, + ]; + const createdAt = new Date(); + + const { id, body, lastEditableCommentCreatedAt, status, author_id } = input; + + const result = await collection(db).findOneAndUpdate( + { + id, + tenant_id: tenantID, + author_id, + status: { + $in: EDITABLE_STATUSES, + }, + deleted_at: null, + created_at: { + $gt: lastEditableCommentCreatedAt, + }, + }, + { + $set: { + body, + status, + }, + $push: { + body_history: { + body, + created_at: createdAt, + }, + status_history: { + type: status, + created_at: createdAt, + }, + }, + }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + if (!result.value) { + // Try to get the comment. + const comment = await retrieveComment(db, tenantID, id); + if (!comment) { + // TODO: (wyattjoh) return better error + throw new Error("comment not found"); + } + + if (comment.author_id !== author_id) { + // TODO: (wyattjoh) return better error + throw new Error("comment author mismatch"); + } + + // Check to see if the comment had a status that was editable. + if (!EDITABLE_STATUSES.includes(comment.status)) { + // TODO: (wyattjoh) return better error + throw new Error("comment status is not editable"); + } + + // Check to see if the edit window expired. + if (comment.created_at <= lastEditableCommentCreatedAt) { + // TODO: (wyattjoh) return better error + throw new Error("edit window expired"); + } + + // TODO: (wyattjoh) return better error + throw new Error("comment edit failed for an unexpected reason"); + } + + return result.value; +} + export async function retrieveComment(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } @@ -129,7 +220,7 @@ export interface ConnectionInput { } function cursorGetterFactory( - input: ConnectionInput + input: Pick ): NodeToCursorTransformer { switch (input.orderBy) { case GQLCOMMENT_SORT.CREATED_AT_DESC: diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 1c4531f3e..b356b919b 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -5,6 +5,8 @@ import { retrieveAsset } from "talk-server/models/asset"; import { createComment, CreateCommentInput, + editComment, + EditCommentInput, retrieveComment, } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; @@ -24,13 +26,14 @@ export async function create( input: CreateComment, req?: Request ) { + // Grab the asset that we'll use to check moderation pieces with. const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); if (!asset) { // TODO: (wyattjoh) return better error. throw new Error("asset referenced does not exist"); } - // TODO: (wyattjoh) Check that the asset was visable. + // TODO: (wyattjoh) Check that the asset was visible. if (input.parent_id) { // Check to see that the reference parent ID exists. @@ -67,3 +70,55 @@ export async function create( return comment; } + +export type EditComment = Omit< + EditCommentInput, + "status" | "author_id" | "lastEditableCommentCreatedAt" +> & { + /** + * asset_id is the asset that the comment exists on. + */ + asset_id: string; +}; + +export async function edit( + mongo: Db, + tenant: Tenant, + author: User, + input: EditComment, + req?: Request +) { + // Grab the asset that we'll use to check moderation pieces with. + const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); + if (!asset) { + // TODO: (wyattjoh) return better error. + throw new Error("asset referenced does not exist"); + } + + // Run the comment through the moderation phases. + const { status } = await processForModeration({ + asset, + tenant, + comment: input, + author, + req, + }); + + // TODO: (wyattjoh) use the actions somehow. + + const comment = await editComment(mongo, tenant.id, { + id: input.id, + author_id: author.id, + body: input.body, + status, + // The editable time is based on the current time, and the edit window + // length. By subtracting the current date from the edit window length, we + // get the maximum value for the `created_at` time that would be permitted + // for the comment edit to succeed. + lastEditableCommentCreatedAt: new Date( + Date.now() - tenant.editCommentWindowLength + ), + }); + + return comment; +} diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index 33f286320..d46be2c59 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -2,9 +2,9 @@ import { Omit, Promiseable } from "talk-common/types"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; import { Action } from "talk-server/models/actions"; import { Asset } from "talk-server/models/asset"; +import { Comment } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; -import { CreateComment } from "talk-server/services/comments"; import { Request } from "talk-server/types/express"; import { moderationPhases } from "./phases"; @@ -24,7 +24,7 @@ export interface PhaseResult { export interface ModerationPhaseContext { asset: Asset; tenant: Tenant; - comment: CreateComment; + comment: Partial; author: User; req?: Request; } diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts index 82a4ada04..cb51f1c56 100644 --- a/src/core/server/services/comments/moderation/phases/commentLength.ts +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -17,7 +17,7 @@ export const commentLength: IntermediateModerationPhase = ({ tenant, comment, }): IntermediatePhaseResult | void => { - const length = comment.body.length; + const length = comment.body ? comment.body.length : 0; // Check to see if the body is too short, if it is, then complain about it! if (length < 2) { diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts index 18628920a..2eed3715e 100755 --- a/src/core/server/services/comments/moderation/phases/links.ts +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -30,8 +30,9 @@ export const links: IntermediateModerationPhase = ({ comment, }): IntermediatePhaseResult | void => { if ( - testPremodLinksEnable(tenant, comment.body) || - (asset.settings && testPremodLinksEnable(asset.settings, comment.body)) + comment.body && + (testPremodLinksEnable(tenant, comment.body) || + (asset.settings && testPremodLinksEnable(asset.settings, comment.body))) ) { // Add the flag related to Trust to the comment. return { diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts index 4962add1b..a15e47d9a 100644 --- a/src/core/server/services/comments/moderation/phases/spam.ts +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -48,6 +48,11 @@ export const spam: IntermediateModerationPhase = async ({ return; } + // If the comment doesn't have a body, it can't be spam! + if (!comment.body) { + return; + } + // Create the Akismet client. const client = new Client({ key: integration.key, diff --git a/src/core/server/services/comments/moderation/phases/staff.ts b/src/core/server/services/comments/moderation/phases/staff.ts index aad2a931d..13d130df1 100755 --- a/src/core/server/services/comments/moderation/phases/staff.ts +++ b/src/core/server/services/comments/moderation/phases/staff.ts @@ -9,9 +9,6 @@ import { // If a given user is a staff member, always approve their comment. export const staff: IntermediateModerationPhase = ({ - asset, - tenant, - comment, author, }): IntermediatePhaseResult | void => { if (author.role !== GQLUSER_ROLE.COMMENTER) { diff --git a/src/core/server/services/comments/moderation/phases/toxic.ts b/src/core/server/services/comments/moderation/phases/toxic.ts index 403a50f5e..8232b1767 100644 --- a/src/core/server/services/comments/moderation/phases/toxic.ts +++ b/src/core/server/services/comments/moderation/phases/toxic.ts @@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({ tenant, comment, }): Promise => { + if (!comment.body) { + return; + } + const integration = tenant.integrations.perspective; if (!integration.enabled) { diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts index a817d3fea..b129093cb 100755 --- a/src/core/server/services/comments/moderation/phases/wordlist.ts +++ b/src/core/server/services/comments/moderation/phases/wordlist.ts @@ -14,6 +14,11 @@ export const wordlist: IntermediateModerationPhase = ({ tenant, comment, }): IntermediatePhaseResult | void => { + // If there isn't a body, there can't be a bad word! + if (!comment.body) { + return; + } + // Decide the status based on whether or not the current asset/settings // has pre-mod enabled or not. If the comment was rejected based on the // wordlist, then reject it, otherwise if the moderation setting is