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
+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;
}