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:
@@ -0,0 +1,45 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#decodeActionCounts parses the action counts correctly 1`] = `
|
||||
Object {
|
||||
"DONT_AGREE": 1,
|
||||
"FLAG": 2,
|
||||
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
|
||||
"REACTION": 3,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#decodeActionCounts parses the action counts correctly 2`] = `
|
||||
Object {
|
||||
"dontAgree": Object {
|
||||
"total": 1,
|
||||
},
|
||||
"flag": Object {
|
||||
"reasons": Object {
|
||||
"COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"COMMENT_DETECTED_BODY_COUNT": 1,
|
||||
"COMMENT_DETECTED_LINKS": 0,
|
||||
"COMMENT_DETECTED_SPAM": 0,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 0,
|
||||
"COMMENT_DETECTED_TOXIC": 0,
|
||||
"COMMENT_DETECTED_TRUST": 0,
|
||||
"COMMENT_REPORTED_OFFENSIVE": 0,
|
||||
"COMMENT_REPORTED_SPAM": 0,
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
"reaction": Object {
|
||||
"total": 3,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#encodeActionCounts generates the action counts correctly 1`] = `
|
||||
Object {
|
||||
"DONT_AGREE": 1,
|
||||
"FLAG": 2,
|
||||
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
ACTION_TYPE,
|
||||
CommentAction,
|
||||
decodeActionCounts,
|
||||
encodeActionCounts,
|
||||
validateAction,
|
||||
} from "talk-server/models/action/comment";
|
||||
|
||||
describe("#encodeActionCounts", () => {
|
||||
it("generates the action counts correctly", () => {
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{ actionType: ACTION_TYPE.DONT_AGREE },
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
];
|
||||
const actionCounts = encodeActionCounts(...(actions as CommentAction[]));
|
||||
|
||||
expect(actionCounts).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#decodeActionCounts", () => {
|
||||
it("parses the action counts correctly", () => {
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.DONT_AGREE },
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
];
|
||||
|
||||
const modelActionCounts = encodeActionCounts(
|
||||
...(actions as CommentAction[])
|
||||
);
|
||||
|
||||
expect(modelActionCounts).toMatchSnapshot();
|
||||
|
||||
const actionCounts = decodeActionCounts(modelActionCounts);
|
||||
|
||||
expect(actionCounts).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#validateAction", () => {
|
||||
it("allows a valid action", () => {
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
validateAction(action as CommentAction);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allow an invalid action", () => {
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
{
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
expect(() => validateAction(action as CommentAction)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,614 @@
|
||||
import Joi from "joi";
|
||||
import { camelCase, pick } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import {
|
||||
GQLActionCounts,
|
||||
GQLActionPresence,
|
||||
GQLCOMMENT_FLAG_DETECTED_REASON,
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_FLAG_REPORTED_REASON,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { FilterQuery } from "talk-server/models/query";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
function collection(db: Db) {
|
||||
return db.collection<Readonly<CommentAction>>("commentActions");
|
||||
}
|
||||
|
||||
export enum ACTION_TYPE {
|
||||
/**
|
||||
* REACTION corresponds to a reaction to a comment from a user.
|
||||
*/
|
||||
REACTION = "REACTION",
|
||||
|
||||
/**
|
||||
* DONT_AGREE corresponds to when a user marks a given comment that they don't
|
||||
* agree with.
|
||||
*/
|
||||
DONT_AGREE = "DONT_AGREE",
|
||||
|
||||
/**
|
||||
* FLAG corresponds to a flag action that indicates that the given resource needs
|
||||
* moderator attention.
|
||||
*/
|
||||
FLAG = "FLAG",
|
||||
}
|
||||
|
||||
export type EncodedCommentActionCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* FLAG_REASON is the reason that a given Flag has been created.
|
||||
*/
|
||||
export type FLAG_REASON =
|
||||
| GQLCOMMENT_FLAG_DETECTED_REASON
|
||||
| GQLCOMMENT_FLAG_REPORTED_REASON
|
||||
| GQLCOMMENT_FLAG_REASON;
|
||||
|
||||
export interface CommentAction extends TenantResource {
|
||||
/**
|
||||
* id is the identifier for this specific Action.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* actionType is the type of Action that this represents.
|
||||
*/
|
||||
actionType: ACTION_TYPE;
|
||||
|
||||
/**
|
||||
* commentID is the ID of the specific item that this Action is associated with.
|
||||
*/
|
||||
commentID: string;
|
||||
|
||||
/**
|
||||
* commentRevisionID is the ID of the specific comment text that the Action
|
||||
* is relating to.
|
||||
*/
|
||||
commentRevisionID: string;
|
||||
|
||||
/**
|
||||
* reason is the reason or secondary grouping identifier for why this
|
||||
* particular action was left.
|
||||
*/
|
||||
reason?: FLAG_REASON;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
storyID?: string;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
userID: string | null;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this particular Action was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* metadata is arbitrary information stored for this Action.
|
||||
*/
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
const ActionSchema = [
|
||||
// Flags
|
||||
{
|
||||
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
|
||||
{
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
},
|
||||
// Reaction
|
||||
{
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* validateAction is used to validate that a specific action conforms to the
|
||||
* expected schema, `ActionSchema`.
|
||||
*/
|
||||
export function validateAction(
|
||||
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, ["actionType", "reason"]),
|
||||
ActionSchema,
|
||||
{
|
||||
presence: "required",
|
||||
abortEarly: false,
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
// TODO: wrap error?
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateActionInput = Omit<
|
||||
CommentAction,
|
||||
"id" | "tenantID" | "createdAt"
|
||||
>;
|
||||
|
||||
export interface CreateActionResultObject {
|
||||
/**
|
||||
* action contains the resultant action that was created.
|
||||
*/
|
||||
action: CommentAction;
|
||||
|
||||
/**
|
||||
* wasUpserted when true, indicates that this action was just newly created.
|
||||
* When false, it indicates that this action was just looked up, and had
|
||||
* existed prior to the `createAction` call.
|
||||
*/
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function createAction(
|
||||
mongo: Db,
|
||||
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<CommentAction, CreateActionInput> = {
|
||||
id,
|
||||
tenantID,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Merge the defaults with the input.
|
||||
const action: Readonly<CommentAction> = {
|
||||
...defaults,
|
||||
...input,
|
||||
};
|
||||
|
||||
// Create the upsert/update operation.
|
||||
const update: { $setOnInsert: Readonly<CommentAction> } = {
|
||||
$setOnInsert: action,
|
||||
};
|
||||
|
||||
// Insert the action into the database using an upsert operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(filter, update, {
|
||||
// We are using this to create a action, so we need to upsert it.
|
||||
upsert: true,
|
||||
|
||||
// False to return the updated document instead of the original document.
|
||||
// This lets us detect if the document was updated or not.
|
||||
returnOriginal: false,
|
||||
});
|
||||
|
||||
// Check to see if this was a new action that was upserted, or one was found
|
||||
// that matched existing records. We are sure here that the record exists
|
||||
// because we're returning the updated document and performing an upsert
|
||||
// operation.
|
||||
|
||||
// Because it's relevant that we know that the action was just created, or
|
||||
// was just looked up, we need to return the action with an object that
|
||||
// indicates if it was upserted.
|
||||
const wasUpserted = result.value!.id === id;
|
||||
|
||||
// Return the action that was created/found with a boolean indicating if this
|
||||
// action was just upserted (and therefore was newly created).
|
||||
return {
|
||||
action: result.value!,
|
||||
wasUpserted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
inputs: CreateActionInput[]
|
||||
): Promise<CreateActionResultObject[]> {
|
||||
// TODO: (wyattjoh) replace with a batch write.
|
||||
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.
|
||||
*/
|
||||
export async function retrieveManyUserActionPresence(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
userID: string | null,
|
||||
commentIDs: string[]
|
||||
): Promise<GQLActionPresence[]> {
|
||||
const cursor = await collection(mongo).find(
|
||||
{
|
||||
tenantID,
|
||||
userID,
|
||||
commentID: { $in: commentIDs },
|
||||
},
|
||||
{
|
||||
// We only need the commentID and actionType from the database.
|
||||
projection: {
|
||||
commentID: 1,
|
||||
actionType: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const actions = await cursor.toArray();
|
||||
|
||||
// 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 commentIDs
|
||||
.map(commentID => actions.filter(action => action.commentID === commentID))
|
||||
.map(itemActions =>
|
||||
itemActions.reduce(
|
||||
(actionPresence, { actionType }) => ({
|
||||
...actionPresence,
|
||||
[camelCase(actionType)]: true,
|
||||
}),
|
||||
{
|
||||
reaction: false,
|
||||
dontAgree: false,
|
||||
flag: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export type RemoveActionInput = Pick<
|
||||
CommentAction,
|
||||
"actionType" | "commentID" | "commentRevisionID" | "reason" | "userID"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The result returned by `removeAction`.
|
||||
*/
|
||||
export interface RemovedActionResultObject {
|
||||
/**
|
||||
* action is the action that was deleted.
|
||||
*/
|
||||
action?: CommentAction;
|
||||
|
||||
/**
|
||||
* wasRemoved is true when the action that was supposed to be deleted was
|
||||
* actually deleted.
|
||||
*/
|
||||
wasRemoved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeAction will delete the action based on the form of the action rather
|
||||
* than a specific action by ID.
|
||||
*/
|
||||
export async function removeAction(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: RemoveActionInput
|
||||
): Promise<RemovedActionResultObject> {
|
||||
const { reason, ...rest } = input;
|
||||
|
||||
// Extract the filter parameters.
|
||||
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 (reason) {
|
||||
filter.reason = reason;
|
||||
}
|
||||
|
||||
// Remove the action from the database, returning the action that was deleted.
|
||||
const result = await collection(mongo).findOneAndDelete(filter);
|
||||
return {
|
||||
action: result.value,
|
||||
wasRemoved: Boolean(result.ok && result.value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ACTION_COUNT_JOIN_CHAR is the character that is used to separate the reason
|
||||
* from the action type when storing the action counts in the models.
|
||||
*/
|
||||
export const ACTION_COUNT_JOIN_CHAR = "__";
|
||||
|
||||
/**
|
||||
* encodeActionCounts will take a list of actions, and generate action counts
|
||||
* from it.
|
||||
*
|
||||
* @param actions list of actions to generate the action counts from
|
||||
*/
|
||||
export function encodeActionCounts(
|
||||
...actions: CommentAction[]
|
||||
): EncodedCommentActionCounts {
|
||||
const actionCounts: EncodedCommentActionCounts = {};
|
||||
|
||||
// Loop over the actions, and increment them.
|
||||
for (const action of actions) {
|
||||
for (const key of encodeActionCountKeys(action)) {
|
||||
if (key in actionCounts) {
|
||||
actionCounts[key]++;
|
||||
} else {
|
||||
actionCounts[key] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actionCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* invertEncodedActionCounts will allow inverting of the action count object.
|
||||
*
|
||||
* @param actionCounts the encoded action counts to invert
|
||||
*/
|
||||
export function invertEncodedActionCounts(
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
): EncodedCommentActionCounts {
|
||||
for (const key in actionCounts) {
|
||||
if (!actionCounts.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actionCounts[key] > 0) {
|
||||
actionCounts[key] = -actionCounts[key];
|
||||
}
|
||||
}
|
||||
|
||||
return actionCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* encodeActionCountKeys encodes the action into string keys which represents
|
||||
* the groupings as seen in `EncodedActionCounts`.
|
||||
*/
|
||||
function encodeActionCountKeys(action: CommentAction): string[] {
|
||||
const keys = [action.actionType as string];
|
||||
if (action.reason) {
|
||||
keys.push(
|
||||
[action.actionType as string, action.reason as string].join(
|
||||
ACTION_COUNT_JOIN_CHAR
|
||||
)
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface DecodedActionCountKey {
|
||||
/**
|
||||
* actionType stores the action type referenced by the key.
|
||||
*/
|
||||
actionType: ACTION_TYPE;
|
||||
|
||||
/**
|
||||
* reason stores the reason referenced by the key if the actionType is FLAG.
|
||||
*/
|
||||
reason?: GQLCOMMENT_FLAG_REASON;
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeActionCountGroup will unpack the key as it is encoded into the separate
|
||||
* actionType and reason.
|
||||
*/
|
||||
function decodeActionCountKey(key: string): DecodedActionCountKey {
|
||||
let actionType: string = "";
|
||||
let reason: string = "";
|
||||
|
||||
if (key.indexOf(ACTION_COUNT_JOIN_CHAR) >= 0) {
|
||||
const keys = key.split(ACTION_COUNT_JOIN_CHAR);
|
||||
if (keys.length !== 2) {
|
||||
throw new Error(
|
||||
"invalid action count contained more than two components"
|
||||
);
|
||||
}
|
||||
|
||||
actionType = keys[0];
|
||||
reason = keys[1];
|
||||
|
||||
// Validate that the action type is flag.
|
||||
if (actionType !== ACTION_TYPE.FLAG) {
|
||||
throw new Error("invalid action type, expected only flag to have reason");
|
||||
}
|
||||
|
||||
// Validate that the reason is valid.
|
||||
if (!reason || !(reason in GQLCOMMENT_FLAG_REASON)) {
|
||||
throw new Error("expected flag to have a reason that was valid");
|
||||
}
|
||||
} else {
|
||||
actionType = key;
|
||||
}
|
||||
|
||||
// Validate that the action type is valid.
|
||||
if (!actionType || !(actionType in ACTION_TYPE)) {
|
||||
throw new Error("expected action to have an action type that was valid");
|
||||
}
|
||||
|
||||
const result: DecodedActionCountKey = {
|
||||
actionType: actionType as ACTION_TYPE,
|
||||
};
|
||||
|
||||
// Merge in the reason if it's provided. If we got here, we know that the
|
||||
// reason is a GQLCOMMENT_FLAG_REASON.
|
||||
if (reason) {
|
||||
result.reason = reason as GQLCOMMENT_FLAG_REASON;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* createEmptyActionCounts creates a default/empty set of action counts.
|
||||
*/
|
||||
function createEmptyActionCounts(): GQLActionCounts {
|
||||
return {
|
||||
reaction: {
|
||||
total: 0,
|
||||
},
|
||||
dontAgree: {
|
||||
total: 0,
|
||||
},
|
||||
flag: {
|
||||
total: 0,
|
||||
// Note that this isn't type checked.. We force it because TS can't
|
||||
// understand the reduce.
|
||||
reasons: Object.keys(GQLCOMMENT_FLAG_REASON).reduce(
|
||||
(reasons, reason) => ({
|
||||
...reasons,
|
||||
[reason]: 0,
|
||||
}),
|
||||
{}
|
||||
) as Record<GQLCOMMENT_FLAG_REASON, number>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCommentActionCounts(
|
||||
actionCounts: EncodedCommentActionCounts[]
|
||||
): EncodedCommentActionCounts {
|
||||
const mergedActionCounts: EncodedCommentActionCounts = {};
|
||||
|
||||
for (const counts of actionCounts) {
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
if (key in mergedActionCounts) {
|
||||
mergedActionCounts[key] += count;
|
||||
} else {
|
||||
mergedActionCounts[key] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedActionCounts;
|
||||
}
|
||||
|
||||
export function countTotalActionCounts(
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
): number {
|
||||
return Object.values(actionCounts).reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeActionCounts will take the encoded action counts and decode them into
|
||||
* a useable format.
|
||||
*
|
||||
* @param encodedActionCounts the action counts to decode
|
||||
*/
|
||||
export function decodeActionCounts(
|
||||
encodedActionCounts: EncodedCommentActionCounts
|
||||
): GQLActionCounts {
|
||||
// Default all the action counts to zero.
|
||||
const actionCounts: GQLActionCounts = createEmptyActionCounts();
|
||||
|
||||
// Loop over all the encoded action counts to extract each of the action
|
||||
// counts as they are encoded.
|
||||
Object.entries(encodedActionCounts).forEach(([key, count]) => {
|
||||
// Pull out the action type and the reason from the key.
|
||||
const { actionType, reason } = decodeActionCountKey(key);
|
||||
|
||||
// Handle the different types and reasons.
|
||||
incrementActionCounts(actionCounts, actionType, reason, count);
|
||||
});
|
||||
|
||||
return actionCounts;
|
||||
}
|
||||
|
||||
function incrementActionCounts(
|
||||
actionCounts: GQLActionCounts,
|
||||
actionType: ACTION_TYPE,
|
||||
reason: GQLCOMMENT_FLAG_REASON | undefined,
|
||||
count: number = 1
|
||||
) {
|
||||
switch (actionType) {
|
||||
case ACTION_TYPE.REACTION:
|
||||
actionCounts.reaction.total += count;
|
||||
break;
|
||||
case ACTION_TYPE.DONT_AGREE:
|
||||
actionCounts.dontAgree.total += count;
|
||||
break;
|
||||
case ACTION_TYPE.FLAG:
|
||||
// When we have a reason, we are incrementing for that particular reason
|
||||
// rather than incrementing for the total. If we don't have a reason, we
|
||||
// just got the updated reason.
|
||||
if (reason) {
|
||||
actionCounts.flag.reasons[reason] += count;
|
||||
} else {
|
||||
actionCounts.flag.total += count;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error("unexpected action type");
|
||||
}
|
||||
|
||||
return actionCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeRootActions will remove all the Action's associated with a given root
|
||||
* identifier.
|
||||
*/
|
||||
export async function removeStoryActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
storyID: string
|
||||
) {
|
||||
return collection(mongo).deleteMany({
|
||||
tenantID,
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* mergeManyRootActions will update many Action `storyID`'s from one to
|
||||
* another.
|
||||
*/
|
||||
export async function mergeManyStoryActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
newStoryID: string,
|
||||
oldStoryIDs: string[]
|
||||
) {
|
||||
return collection(mongo).updateMany(
|
||||
{
|
||||
tenantID,
|
||||
storyID: {
|
||||
$in: oldStoryIDs,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user