[next] Comment Editing (#1795)

* feat: initial impl of comment edits

* feat: added edit metadata

* review: switched out explcit null + nullable types

* fix: changed commentEdge -> edge, test comments

* [next] Tasks (#1777)

* feat: initial support for synced tenants

* fix: cleanup

* fix: logger now respects logging level

* fix: cache now ignores updates issued from itself

* feat: print subscriber count

* feat: initial moderation + validation for new comments

* fix: added Promiseable type

* feat: initial actions impl

* feat: more moderation phases

* fix: handle settings inheritence

* fix: moved settings into new file

* fix: defaults and documentation

* fix: replace merge with object spread

* feat: added integration with akismet

* fix: support tenant cache for oidc strategy

* fix: fixed compile

* fix: import ordering

* feat: added bull for queue support

* feat: support for scraping

* fix: fixes for scraper

- Implemented simple metascraper replacement (to resolve security advisory
  warning)
- Implemented simle dotize replacement (to resolve not
  working version that couldn't handle date objects)
- Plugged in asset scraping to asset creation process

* fix: handles array values

* feat: added initial scraper implementation

* feat: seperate queues but share config

* fix: simplified auth data access

* feat: moved more settings into the graph

* feat: improved mailer design

* fix: fixed issue with dotize

* fix: fixed some issues with adapter

* fix: queue cleanup

* feat: added organizationName to Tenant

* feat: email rendering

* review: support es6 imports

* fix: restore old ci step

* fix: adjusted logging messages

* fix: linting
This commit is contained in:
Wyatt Johnson
2018-09-04 18:57:41 +00:00
committed by GitHub
parent 59cf728681
commit 3897b06029
15 changed files with 289 additions and 30 deletions
@@ -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",
},
],
});
@@ -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: "<strong>Hello world!</strong>" });
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();
});
@@ -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<Comment> => {
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
),
});
@@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera
import { Comment } from "talk-server/models/comment";
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),
@@ -1,11 +1,15 @@
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
const Mutation: GQLMutationTypeResolver<void> = {
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,
},
@@ -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.
"""
+92 -1
View File
@@ -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<ConnectionInput, "orderBy" | "after">
): NodeToCursorTransformer<Comment> {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
+56 -1
View File
@@ -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;
}
@@ -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<Comment>;
author: User;
req?: Request;
}
@@ -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) {
@@ -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 {
@@ -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,
@@ -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) {
@@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({
tenant,
comment,
}): Promise<IntermediatePhaseResult | void> => {
if (!comment.body) {
return;
}
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
@@ -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