mirror of
https://github.com/wassname/talk.git
synced 2026-07-03 14:20:14 +08:00
feat: expanded action counts
This commit is contained in:
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,25 +12,37 @@ function collection(db: Db) {
|
||||
return db.collection<Readonly<Action>>("actions");
|
||||
}
|
||||
|
||||
export type ActionCounts = Record<string, number>;
|
||||
|
||||
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<string, number>;
|
||||
|
||||
export interface ActionCountGroup {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ActionCounts {
|
||||
[ACTION_TYPE.REACTION]: ActionCountGroup;
|
||||
[ACTION_TYPE.DONT_AGREE]: ActionCountGroup;
|
||||
[ACTION_TYPE.FLAG]: ActionCountGroup & {
|
||||
reasons: Record<GQLCOMMENT_FLAG_REASON, number>;
|
||||
};
|
||||
}
|
||||
|
||||
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<GQLCOMMENT_FLAG_REASON, number>,
|
||||
},
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user