diff --git a/src/core/server/models/__snapshots__/actions.spec.ts.snap b/src/core/server/models/__snapshots__/actions.spec.ts.snap new file mode 100644 index 000000000..791eabc1e --- /dev/null +++ b/src/core/server/models/__snapshots__/actions.spec.ts.snap @@ -0,0 +1,54 @@ +// 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 { + "DONT_AGREE": 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, +} +`; + +exports[`#generateActionCounts generates the action counts correctly 1`] = ` +Object { + "DONT_AGREE": 1, + "FLAG": 2, + "FLAG__COMMENT_DETECTED_BANNED_WORD": 1, + "FLAG__COMMENT_DETECTED_BODY_COUNT": 1, +} +`; diff --git a/src/core/server/models/actions.spec.ts b/src/core/server/models/actions.spec.ts index d44e8185a..a09595003 100644 --- a/src/core/server/models/actions.spec.ts +++ b/src/core/server/models/actions.spec.ts @@ -3,11 +3,12 @@ import { Action, ACTION_ITEM_TYPE, ACTION_TYPE, - generateActionCounts, + decodeActionCounts, + encodeActionCounts, validateAction, } from "talk-server/models/actions"; -describe("#generateActionCounts", () => { +describe("#encodeActionCounts", () => { it("generates the action counts correctly", () => { const actions = [ { action_type: ACTION_TYPE.DONT_AGREE }, @@ -20,18 +21,36 @@ describe("#generateActionCounts", () => { reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, }, ]; - const actionCounts = generateActionCounts(...(actions as Action[])); + const actionCounts = encodeActionCounts(...(actions as Action[])); - expect(actionCounts).toEqual({ - [ACTION_TYPE.DONT_AGREE.toLowerCase()]: 1, - [ACTION_TYPE.FLAG.toLowerCase()]: 2, - [ACTION_TYPE.FLAG.toLowerCase() + - "_" + - GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD.toLowerCase()]: 1, - [ACTION_TYPE.FLAG.toLowerCase() + - "_" + - GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT.toLowerCase()]: 1, - }); + expect(actionCounts).toMatchSnapshot(); + }); +}); + +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 }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD, + }, + { + action_type: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT, + }, + ]; + + const modelActionCounts = encodeActionCounts(...(actions as Action[])); + + expect(modelActionCounts).toMatchSnapshot(); + + const actionCounts = decodeActionCounts(modelActionCounts); + + expect(actionCounts).toMatchSnapshot(); }); }); diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts index 291fa9a8a..93bc992d8 100644 --- a/src/core/server/models/actions.ts +++ b/src/core/server/models/actions.ts @@ -12,25 +12,37 @@ function collection(db: Db) { return db.collection>("actions"); } -export type ActionCounts = Record; - export enum ACTION_TYPE { /** * REACTION corresponds to a reaction to a comment from a user. */ REACTION = "REACTION", - /** - * FLAG corresponds to a flag action that indicates that the given resource needs - * moderator attention. - */ - FLAG = "FLAG", - /** * 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 EncodedActionCounts = Record; + +export interface ActionCountGroup { + total: number; +} + +export interface ActionCounts { + [ACTION_TYPE.REACTION]: ActionCountGroup; + [ACTION_TYPE.DONT_AGREE]: ActionCountGroup; + [ACTION_TYPE.FLAG]: ActionCountGroup & { + reasons: Record; + }; } export enum ACTION_ITEM_TYPE { @@ -182,44 +194,169 @@ export async function createActions( } /** - * generateActionCounts will take a list of actions, and generate action counts + * 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 generateActionCounts(...actions: Action[]): ActionCounts { - const actionCounts: ActionCounts = {}; +export function encodeActionCounts(...actions: Action[]): EncodedActionCounts { + const actionCounts: EncodedActionCounts = {}; - /** - * increment the key in the action counts variable. - */ - function incr(...keys: string[]) { - const key = keys.join("_"); + // 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; } } - - function transform(action: Action) { - const actionType = action.action_type.toLowerCase(); - - // Add the action type to the action counts. - incr(actionType); - - // Check if the reason is set. - const reason = action.reason && action.reason.toLowerCase(); - if (reason) { - // Add the action type to the action counts. - incr(actionType, reason); - } - } - - // Loop over the actions, and increment them. - for (const action of actions) { - transform(action); } return actionCounts; } + +/** + * 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]; + if (action.reason) { + keys.push( + [action.action_type 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; + } + +/** + * 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: EncodedActionCounts +): ActionCounts { + // Default all the action counts to zero. + const actionCounts: ActionCounts = { + [ACTION_TYPE.REACTION]: { + total: 0, + }, + [ACTION_TYPE.DONT_AGREE]: { + total: 0, + }, + [ACTION_TYPE.FLAG]: { + total: 0, + reasons: Object.keys(GQLCOMMENT_FLAG_REASON).reduce( + (reasons, reason) => ({ + ...reasons, + [reason]: 0, + }), + {} + ) as Record, + }, + }; + + // 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. + switch (actionType) { + case ACTION_TYPE.REACTION: + actionCounts[ACTION_TYPE.REACTION].total += count; + break; + case ACTION_TYPE.DONT_AGREE: + actionCounts[ACTION_TYPE.DONT_AGREE].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[ACTION_TYPE.FLAG].reasons[reason] += count; + } else { + actionCounts[ACTION_TYPE.FLAG].total += count; + } + break; + default: + throw new Error("unexpected action type"); + } + }); + + return actionCounts; +} diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index bea38ea20..2fa32efc1 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -7,7 +7,7 @@ import { GQLCOMMENT_SORT, GQLCOMMENT_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ActionCounts } from "talk-server/models/actions"; +import { EncodedActionCounts } from "talk-server/models/actions"; import { Connection, Cursor, @@ -42,7 +42,7 @@ export interface Comment extends TenantResource { body_history: BodyHistoryItem[]; status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; - action_counts: ActionCounts; + action_counts: EncodedActionCounts; reply_count: number; created_at: Date; deleted_at?: Date; @@ -386,7 +386,7 @@ export async function updateCommentActionCounts( mongo: Db, tenantID: string, id: string, - actionCounts: ActionCounts + actionCounts: EncodedActionCounts ) { const result = await collection(mongo).findOneAndUpdate( { id, tenant_id: tenantID }, diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 9b1d04343..8bfac404a 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -7,7 +7,7 @@ import { GQLUSER_ROLE, GQLUSER_USERNAME_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { ActionCounts } from "talk-server/models/actions"; +import { EncodedActionCounts } from "talk-server/models/actions"; import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; @@ -70,7 +70,7 @@ export interface User extends TenantResource { tokens: Token[]; role: GQLUSER_ROLE; status: UserStatus; - action_counts: ActionCounts; + action_counts: EncodedActionCounts; ignored_users: string[]; created_at: Date; } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 6a359cee3..2bddddc44 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -5,7 +5,7 @@ import { ACTION_ITEM_TYPE, CreateActionInput, createActions, - generateActionCounts, + encodeActionCounts, } from "talk-server/models/actions"; import { retrieveAsset } from "talk-server/models/asset"; import { @@ -186,7 +186,7 @@ async function addCommentActions( if (upsertedActions.length > 0) { // Compute the action counts. - const actionCounts = generateActionCounts(...upsertedActions); + const actionCounts = encodeActionCounts(...upsertedActions); // Update the comment action counts here. const updatedComment = await updateCommentActionCounts(