feat: initial action implementations

This commit is contained in:
Wyatt Johnson
2018-09-06 16:24:34 -06:00
parent 53e548d77a
commit 1bd841ec01
6 changed files with 416 additions and 14 deletions
@@ -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 {
+112
View File
@@ -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();
}
});
});
+191 -1
View File
@@ -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;
}
+28
View File
@@ -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;
}
+67 -5
View File
@@ -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>;
}