[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:
Wyatt Johnson
2018-11-21 16:42:47 +00:00
committed by Kiwi
parent 13147c4ba4
commit 21e1a5cbef
66 changed files with 2323 additions and 2224 deletions
@@ -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
View File
@@ -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];
}
+31 -31
View File
@@ -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,
},
+5 -1
View File
@@ -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;
}
/**
+13 -22
View File
@@ -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 }
);