diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 79d9a73a8..9d777cf10 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -31,8 +31,22 @@ scalar Cursor ################################################################################ enum ACTION_TYPE { + """ + REACTION corresponds to a reaction to a comment from a user. + """ + REACTION + + """ + FLAG corresponds to a flag action that indicates that the given resource needs + moderator attention. + """ FLAG - DONTAGREE + + """ + DONT_AGREE corresponds to when a user marks a given comment that they don't + agree with. + """ + DONT_AGREE } enum ACTION_ITEM_TYPE { diff --git a/src/core/server/models/actions.spec.ts b/src/core/server/models/actions.spec.ts new file mode 100644 index 000000000..c1bf862ce --- /dev/null +++ b/src/core/server/models/actions.spec.ts @@ -0,0 +1,112 @@ +import { + GQLACTION_GROUP, + GQLACTION_ITEM_TYPE, + GQLACTION_TYPE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { + Action, + generateActionCounts, + validateAction, +} from "talk-server/models/actions"; + +describe("#generateActionCounts", () => { + it("generates the action counts correctly", () => { + const actions = [ + { action_type: GQLACTION_TYPE.DONT_AGREE }, + { + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.BANNED_WORD, + }, + { + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.BODY_COUNT, + }, + ]; + const actionCounts = generateActionCounts(...(actions as Action[])); + + expect(actionCounts).toEqual({ + [GQLACTION_TYPE.DONT_AGREE.toLowerCase()]: 1, + [GQLACTION_TYPE.FLAG.toLowerCase()]: 2, + [GQLACTION_TYPE.FLAG.toLowerCase() + + "_" + + GQLACTION_GROUP.BANNED_WORD.toLowerCase()]: 1, + [GQLACTION_TYPE.FLAG.toLowerCase() + + "_" + + GQLACTION_GROUP.BODY_COUNT.toLowerCase()]: 1, + }); + }); +}); + +describe("#validateAction", () => { + it("allows a valid action", () => { + const actions = [ + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.REACTION, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.DONT_AGREE, + }, + + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.SPAM_COMMENT, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.TOXIC_COMMENT, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.BODY_COUNT, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.TRUST, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.LINKS, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.BANNED_WORD, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.FLAG, + group_id: GQLACTION_GROUP.SUSPECT_WORD, + }, + ]; + + for (const action of actions) { + validateAction(action as Action); + } + }); + + it("does not allow an invalid action", () => { + const actions = [ + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.DONT_AGREE, + group_id: GQLACTION_GROUP.SPAM_COMMENT, + }, + { + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + action_type: GQLACTION_TYPE.DONT_AGREE, + group_id: GQLACTION_GROUP.BODY_COUNT, + }, + ]; + + for (const action of actions) { + expect(() => validateAction(action as Action)).toThrow(); + } + }); +}); diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts index d162b1300..ed4555448 100644 --- a/src/core/server/models/actions.ts +++ b/src/core/server/models/actions.ts @@ -1,12 +1,22 @@ +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { Omit, Sub } from "talk-common/types"; import { GQLACTION_GROUP, GQLACTION_ITEM_TYPE, GQLACTION_TYPE, } 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>("actions"); +} export type ActionCounts = Record; -export interface Action { +export interface Action extends TenantResource { readonly id: string; action_type: GQLACTION_TYPE; item_type: GQLACTION_ITEM_TYPE; @@ -16,3 +26,183 @@ export interface Action { created_at: Date; metadata?: Record; } + +const validActionCombinations: Record< + GQLACTION_ITEM_TYPE, + Record | true> +> = { + [GQLACTION_ITEM_TYPE.COMMENTS]: { + [GQLACTION_TYPE.REACTION]: true, + [GQLACTION_TYPE.DONT_AGREE]: true, + [GQLACTION_TYPE.FLAG]: { + [GQLACTION_GROUP.SPAM_COMMENT]: true, + [GQLACTION_GROUP.TOXIC_COMMENT]: true, + [GQLACTION_GROUP.BODY_COUNT]: true, + [GQLACTION_GROUP.TRUST]: true, + [GQLACTION_GROUP.LINKS]: true, + [GQLACTION_GROUP.BANNED_WORD]: true, + [GQLACTION_GROUP.SUSPECT_WORD]: true, + }, + }, +}; + +export function validateAction( + action: Pick +) { + if (!(action.item_type in validActionCombinations)) { + throw new Error("invalid item_type"); + } + + const itemType = validActionCombinations[action.item_type]; + if (!(action.action_type in itemType)) { + throw new Error("invalid action_type"); + } + + const actionType = itemType[action.action_type]; + if (action.group_id) { + if (actionType === true) { + throw new Error("invalid action_type"); + } + + if (!(action.group_id in actionType)) { + throw new Error("invalid action_type"); + } + } else if (actionType !== true) { + throw new Error("invalid action_type"); + } +} + +export type CreateActionInput = Omit; + +export interface CreateActionResultObject { + /** + * action contains the resultant action that was created. + */ + action: Action; + + /** + * 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 { + // Validate that the action is valid, if it isn't, this will throw an error. + validateAction(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 = { + id, + tenant_id: tenantID, + created_at: new Date(), + }; + + // Merge the defaults with the input. + const action: Readonly = { + ...defaults, + ...input, + }; + + // This filter ensures that a given user can't flag/respect a given user more + // than once. + const filter: FilterQuery = { + action_type: input.action_type, + item_type: input.item_type, + item_id: input.item_id, + group_id: input.group_id, + user_id: input.user_id, + }; + + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $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 { + return Promise.all(inputs.map(input => createAction(mongo, tenantID, input))); +} + +/** + * generateActionCounts 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 generateActionCounts(...actions: Action[]): ActionCounts { + const actionCounts: ActionCounts = {}; + + /** + * increment the key in the action counts variable. + */ + function incr(...keys: string[]) { + const key = keys.join("_"); + if (key in actionCounts) { + actionCounts[key]++; + } else { + actionCounts[key] = 1; + } + } + + function transform(action: Action) { + const actionType = action.action_type.toLowerCase(); + + // Add the action type to the action counts. + incr(actionType); + + // Check if the group id is set. + const groupID = action.group_id && action.group_id.toLowerCase(); + if (groupID) { + // Add the action type to the action counts. + incr(actionType, groupID); + } + } + + // Loop over the actions, and increment them. + for (const action of actions) { + transform(action); + } + + return actionCounts; +} diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index d2c8e03a6..91fa5c903 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -2,6 +2,7 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; +import { dotize } from "talk-common/utils/dotize"; import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, @@ -359,3 +360,30 @@ function applyInputToQuery(input: ConnectionInput, query: Query) { break; } } + +/** + * updateCommentActionCounts will update the given comment's action counts. + * + * @param mongo the database handle + * @param tenantID the id of the tenant + * @param id the id of the comment being updated + * @param actionCounts the action counts to merge into the comment + */ +export async function updateCommentActionCounts( + mongo: Db, + tenantID: string, + id: string, + actionCounts: ActionCounts +) { + const result = await collection(mongo).findOneAndUpdate( + { id, tenant_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 } + ); + + return result.value; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index b356b919b..a2317edb8 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,13 +1,21 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; +import { GQLACTION_ITEM_TYPE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + CreateActionInput, + createActions, + generateActionCounts, +} from "talk-server/models/actions"; import { retrieveAsset } from "talk-server/models/asset"; import { + Comment, createComment, CreateCommentInput, editComment, EditCommentInput, retrieveComment, + updateCommentActionCounts, } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; @@ -47,7 +55,7 @@ export async function create( } // Run the comment through the moderation phases. - const { status, metadata } = await processForModeration({ + const { actions, status, metadata } = await processForModeration({ asset, tenant, comment: input, @@ -55,17 +63,71 @@ export async function create( req, }); - // TODO: (wyattjoh) use the actions somehow. - - const comment = await createComment(mongo, tenant.id, { + // Create the comment! + let comment = await createComment(mongo, tenant.id, { ...input, status, action_counts: {}, metadata, }); + if (actions.length > 0) { + // The actions coming from the moderation phases didn't know the item_id + // at the time, and we didn't want the repetitive nature of adding the + // item_type each time, so this mapping function adds them! + const inputs = actions.map( + (action): CreateActionInput => ({ + ...action, + item_id: comment.id, + item_type: GQLACTION_ITEM_TYPE.COMMENTS, + }) + ); + + // Insert and handle creating the actions. + comment = await addCommentActions(mongo, tenant, comment, inputs); + } + if (input.parent_id) { - // TODO: update reply count of parent. + // TODO: (wyattjoh) update reply count of parent. + } + + return comment; +} + +async function addCommentActions( + mongo: Db, + tenant: Tenant, + comment: Readonly, + inputs: CreateActionInput[] +): Promise> { + // Create each of the actions, returning each of the action results. + const results = await createActions(mongo, tenant.id, inputs); + + // Get the actions that were upserted. + const upsertedActions = results + .filter(({ wasUpserted }) => wasUpserted) + .map(({ action }) => action); + + if (upsertedActions.length > 0) { + // Compute the action counts. + const actionCounts = generateActionCounts(...upsertedActions); + + // Update the comment action counts here. + const updatedComment = await updateCommentActionCounts( + mongo, + tenant.id, + comment.id, + actionCounts + ); + + // Check to see if there was an actual comment returned (there should + // have been, we just created it!). + if (!updatedComment) { + // TODO: (wyattjoh) return a better error. + throw new Error("could not update comment action counts"); + } + + return updatedComment; } return comment; diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index d46be2c59..cc4d91a34 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,6 +1,6 @@ import { Omit, Promiseable } from "talk-common/types"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Action } from "talk-server/models/actions"; +import { CreateActionInput } from "talk-server/models/actions"; import { Asset } from "talk-server/models/asset"; import { Comment } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; @@ -9,14 +9,10 @@ import { Request } from "talk-server/types/express"; import { moderationPhases } from "./phases"; -// TODO: (wyattjoh) move into actions module once we have action methods. -export type CreateAction = Omit< - Action, - "id" | "item_type" | "item_id" | "created_at" ->; +export type ModerationAction = Omit; export interface PhaseResult { - actions: CreateAction[]; + actions: ModerationAction[]; status: GQLCOMMENT_STATUS; metadata: Record; }