mirror of
https://github.com/wassname/talk.git
synced 2026-07-18 12:40:13 +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:
+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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user