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