mirror of
https://github.com/wassname/talk.git
synced 2026-07-07 10:25:04 +08:00
feat: initial action implementations
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Readonly<Action>>("actions");
|
||||
}
|
||||
|
||||
export type ActionCounts = Record<string, number>;
|
||||
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
const validActionCombinations: Record<
|
||||
GQLACTION_ITEM_TYPE,
|
||||
Record<GQLACTION_TYPE, Record<GQLACTION_GROUP, true> | 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<Action, "item_type" | "action_type" | "group_id">
|
||||
) {
|
||||
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<Action, "id" | "tenant_id" | "created_at">;
|
||||
|
||||
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<CreateActionResultObject> {
|
||||
// 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<Action, CreateActionInput> = {
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
// Merge the defaults with the input.
|
||||
const action: Readonly<Action> = {
|
||||
...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,
|
||||
group_id: input.group_id,
|
||||
user_id: input.user_id,
|
||||
};
|
||||
|
||||
// Create the upsert/update operation.
|
||||
const update: { $setOnInsert: Readonly<Action> } = {
|
||||
$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[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Comment>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Comment>,
|
||||
inputs: CreateActionInput[]
|
||||
): Promise<Readonly<Comment>> {
|
||||
// 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;
|
||||
|
||||
@@ -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<CreateActionInput, "item_id" | "item_type">;
|
||||
|
||||
export interface PhaseResult {
|
||||
actions: CreateAction[];
|
||||
actions: ModerationAction[];
|
||||
status: GQLCOMMENT_STATUS;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user