mirror of
https://github.com/wassname/talk.git
synced 2026-07-10 15:38:26 +08:00
[next] Comment Moderation Actions (#2068)
* fix: renamed snake case to camel case * fix: changed case for mutators * fix: renamed all snake case to camel case for db * feat: added support for comment revisions + split comment actions * fix: updated tests * feat: implemented CommentModerationAction * fix: fixed case issues * feat: enabled WeakMap for wordList processsing * chore: npm audit
This commit is contained in:
+33
-44
@@ -1,27 +1,26 @@
|
||||
import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
Action,
|
||||
ACTION_ITEM_TYPE,
|
||||
ACTION_TYPE,
|
||||
CommentAction,
|
||||
decodeActionCounts,
|
||||
encodeActionCounts,
|
||||
validateAction,
|
||||
} from "talk-server/models/action";
|
||||
} from "talk-server/models/action/comment";
|
||||
|
||||
describe("#encodeActionCounts", () => {
|
||||
it("generates the action counts correctly", () => {
|
||||
const actions = [
|
||||
{ action_type: ACTION_TYPE.DONT_AGREE },
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{ actionType: ACTION_TYPE.DONT_AGREE },
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
];
|
||||
const actionCounts = encodeActionCounts(...(actions as Action[]));
|
||||
const actionCounts = encodeActionCounts(...(actions as CommentAction[]));
|
||||
|
||||
expect(actionCounts).toMatchSnapshot();
|
||||
});
|
||||
@@ -29,22 +28,24 @@ describe("#encodeActionCounts", () => {
|
||||
|
||||
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 },
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.REACTION },
|
||||
{ actionType: ACTION_TYPE.DONT_AGREE },
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
];
|
||||
|
||||
const modelActionCounts = encodeActionCounts(...(actions as Action[]));
|
||||
const modelActionCounts = encodeActionCounts(
|
||||
...(actions as CommentAction[])
|
||||
);
|
||||
|
||||
expect(modelActionCounts).toMatchSnapshot();
|
||||
|
||||
@@ -56,77 +57,65 @@ describe("#decodeActionCounts", () => {
|
||||
|
||||
describe("#validateAction", () => {
|
||||
it("allows a valid action", () => {
|
||||
const actions = [
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.REACTION,
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
validateAction(action as Action);
|
||||
validateAction(action as CommentAction);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allow an invalid action", () => {
|
||||
const actions = [
|
||||
const actions: Array<Partial<CommentAction>> = [
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
|
||||
},
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
expect(() => validateAction(action as Action)).toThrow();
|
||||
expect(() => validateAction(action as CommentAction)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { FilterQuery } from "talk-server/models/query";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
function collection(db: Db) {
|
||||
return db.collection<Readonly<Action>>("actions");
|
||||
return db.collection<Readonly<CommentAction>>("commentActions");
|
||||
}
|
||||
|
||||
export enum ACTION_TYPE {
|
||||
@@ -37,15 +37,7 @@ export enum ACTION_TYPE {
|
||||
FLAG = "FLAG",
|
||||
}
|
||||
|
||||
export type EncodedActionCounts = Record<string, number>;
|
||||
|
||||
export interface ActionCountGroup {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum ACTION_ITEM_TYPE {
|
||||
COMMENTS = "COMMENTS",
|
||||
}
|
||||
export type EncodedCommentActionCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* FLAG_REASON is the reason that a given Flag has been created.
|
||||
@@ -55,27 +47,27 @@ export type FLAG_REASON =
|
||||
| GQLCOMMENT_FLAG_REPORTED_REASON
|
||||
| GQLCOMMENT_FLAG_REASON;
|
||||
|
||||
export interface Action extends TenantResource {
|
||||
export interface CommentAction extends TenantResource {
|
||||
/**
|
||||
* id is the identifier for this specific Action.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* action_type is the type of Action that this represents.
|
||||
* actionType is the type of Action that this represents.
|
||||
*/
|
||||
action_type: ACTION_TYPE;
|
||||
actionType: ACTION_TYPE;
|
||||
|
||||
/**
|
||||
* item_type enables polymorphic behavior be allowing multiple item types
|
||||
* to be represented in a single collection.
|
||||
* commentID is the ID of the specific item that this Action is associated with.
|
||||
*/
|
||||
item_type: ACTION_ITEM_TYPE;
|
||||
commentID: string;
|
||||
|
||||
/**
|
||||
* item_id is the ID of the specific item that this Action is associated with.
|
||||
* commentRevisionID is the ID of the specific comment text that the Action
|
||||
* is relating to.
|
||||
*/
|
||||
item_id: string;
|
||||
commentRevisionID: string;
|
||||
|
||||
/**
|
||||
* reason is the reason or secondary grouping identifier for why this
|
||||
@@ -84,22 +76,22 @@ export interface Action extends TenantResource {
|
||||
reason?: FLAG_REASON;
|
||||
|
||||
/**
|
||||
* root_item_id represents the identifier for the item's associated item. In
|
||||
* storyID represents the identifier for the item's associated item. In
|
||||
* the case of a REACTION left on a Comment, this ID would be the Stories ID.
|
||||
* In the case of a FLAG left on a User, this ID would be null.
|
||||
*/
|
||||
root_item_id?: string;
|
||||
storyID?: string;
|
||||
|
||||
/**
|
||||
* user_id is the ID of the User that left this Action. In the event that the
|
||||
* userID is the ID of the User that left this Action. In the event that the
|
||||
* Action was left by Talk, it will be null.
|
||||
*/
|
||||
user_id?: string;
|
||||
userID: string | null;
|
||||
|
||||
/**
|
||||
* created_at is the date that this particular Action was created at.
|
||||
* createdAt is the date that this particular Action was created at.
|
||||
*/
|
||||
created_at: Date;
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* metadata is arbitrary information stored for this Action.
|
||||
@@ -110,21 +102,18 @@ export interface Action extends TenantResource {
|
||||
const ActionSchema = [
|
||||
// Flags
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.FLAG,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
// Only reasons for the flag action will be allowed here, and it must be
|
||||
// specified.
|
||||
reason: Object.keys(GQLCOMMENT_FLAG_REASON),
|
||||
},
|
||||
// Don't Agree
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.DONT_AGREE,
|
||||
actionType: ACTION_TYPE.DONT_AGREE,
|
||||
},
|
||||
// Reaction
|
||||
{
|
||||
item_type: ACTION_ITEM_TYPE.COMMENTS,
|
||||
action_type: ACTION_TYPE.REACTION,
|
||||
actionType: ACTION_TYPE.REACTION,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -133,12 +122,12 @@ const ActionSchema = [
|
||||
* expected schema, `ActionSchema`.
|
||||
*/
|
||||
export function validateAction(
|
||||
action: Pick<Action, "item_type" | "action_type" | "reason">
|
||||
action: Pick<CommentAction, "actionType" | "reason">
|
||||
) {
|
||||
const { error } = Joi.validate(
|
||||
// In typescript, this isn't an issue, but when this is transpiled to
|
||||
// javascript, it will contain additional elements.
|
||||
pick(action, ["item_type", "action_type", "reason"]),
|
||||
pick(action, ["actionType", "reason"]),
|
||||
ActionSchema,
|
||||
{
|
||||
presence: "required",
|
||||
@@ -151,13 +140,16 @@ export function validateAction(
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateActionInput = Omit<Action, "id" | "tenant_id" | "created_at">;
|
||||
export type CreateActionInput = Omit<
|
||||
CommentAction,
|
||||
"id" | "tenantID" | "createdAt"
|
||||
>;
|
||||
|
||||
export interface CreateActionResultObject {
|
||||
/**
|
||||
* action contains the resultant action that was created.
|
||||
*/
|
||||
action: Action;
|
||||
action: CommentAction;
|
||||
|
||||
/**
|
||||
* wasUpserted when true, indicates that this action was just newly created.
|
||||
@@ -172,35 +164,27 @@ export async function createAction(
|
||||
tenantID: string,
|
||||
input: CreateActionInput
|
||||
): Promise<CreateActionResultObject> {
|
||||
const { metadata, ...filter } = 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> = {
|
||||
const defaults: Sub<CommentAction, CreateActionInput> = {
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
created_at: new Date(),
|
||||
tenantID,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Merge the defaults with the input.
|
||||
const action: Readonly<Action> = {
|
||||
const action: Readonly<CommentAction> = {
|
||||
...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,
|
||||
reason: input.reason,
|
||||
user_id: input.user_id,
|
||||
};
|
||||
|
||||
// Create the upsert/update operation.
|
||||
const update: { $setOnInsert: Readonly<Action> } = {
|
||||
const update: { $setOnInsert: Readonly<CommentAction> } = {
|
||||
$setOnInsert: action,
|
||||
};
|
||||
|
||||
@@ -241,6 +225,21 @@ export async function createActions(
|
||||
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
|
||||
}
|
||||
|
||||
export async function retrieveUserAction(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
userID: string | null,
|
||||
commentID: string,
|
||||
actionType: ACTION_TYPE
|
||||
) {
|
||||
return collection(mongo).findOne({
|
||||
tenantID,
|
||||
commentID,
|
||||
userID,
|
||||
actionType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveManyUserActionPresence returns the action presence for a specific
|
||||
* user.
|
||||
@@ -249,21 +248,19 @@ export async function retrieveManyUserActionPresence(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
userID: string | null,
|
||||
itemType: ACTION_ITEM_TYPE,
|
||||
itemIDs: string[]
|
||||
commentIDs: string[]
|
||||
): Promise<GQLActionPresence[]> {
|
||||
const cursor = await collection(mongo).find(
|
||||
{
|
||||
tenant_id: tenantID,
|
||||
user_id: userID,
|
||||
item_type: itemType,
|
||||
item_id: { $in: itemIDs },
|
||||
tenantID,
|
||||
userID,
|
||||
commentID: { $in: commentIDs },
|
||||
},
|
||||
{
|
||||
// We only need the item_id and action_type from the database.
|
||||
// We only need the commentID and actionType from the database.
|
||||
projection: {
|
||||
item_id: 1,
|
||||
action_type: 1,
|
||||
commentID: 1,
|
||||
actionType: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -272,13 +269,13 @@ export async function retrieveManyUserActionPresence(
|
||||
|
||||
// For each of the actions returned by the query, group the actions by the
|
||||
// item id. Then compute the action presence for each of the actions.
|
||||
return itemIDs
|
||||
.map(itemID => actions.filter(action => action.item_id === itemID))
|
||||
return commentIDs
|
||||
.map(commentID => actions.filter(action => action.commentID === commentID))
|
||||
.map(itemActions =>
|
||||
itemActions.reduce(
|
||||
(actionPresence, { action_type }) => ({
|
||||
(actionPresence, { actionType }) => ({
|
||||
...actionPresence,
|
||||
[camelCase(action_type)]: true,
|
||||
[camelCase(actionType)]: true,
|
||||
}),
|
||||
{
|
||||
reaction: false,
|
||||
@@ -290,8 +287,8 @@ export async function retrieveManyUserActionPresence(
|
||||
}
|
||||
|
||||
export type RemoveActionInput = Pick<
|
||||
Action,
|
||||
"action_type" | "item_type" | "item_id" | "reason" | "user_id"
|
||||
CommentAction,
|
||||
"actionType" | "commentID" | "commentRevisionID" | "reason" | "userID"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -301,7 +298,7 @@ export interface RemovedActionResultObject {
|
||||
/**
|
||||
* action is the action that was deleted.
|
||||
*/
|
||||
action?: Action;
|
||||
action?: CommentAction;
|
||||
|
||||
/**
|
||||
* wasRemoved is true when the action that was supposed to be deleted was
|
||||
@@ -319,19 +316,18 @@ export async function removeAction(
|
||||
tenantID: string,
|
||||
input: RemoveActionInput
|
||||
): Promise<RemovedActionResultObject> {
|
||||
const { reason, ...rest } = input;
|
||||
|
||||
// Extract the filter parameters.
|
||||
const filter: FilterQuery<Action> = {
|
||||
tenant_id: tenantID,
|
||||
action_type: input.action_type,
|
||||
item_type: input.item_type,
|
||||
item_id: input.item_id,
|
||||
user_id: input.user_id,
|
||||
const filter: FilterQuery<CommentAction> = {
|
||||
tenantID,
|
||||
...rest,
|
||||
};
|
||||
|
||||
// Only add the reason to the filter if it's been specified, otherwise we'll
|
||||
// never match a Flag that has an unspecified reason.
|
||||
if (input.reason) {
|
||||
filter.reason = input.reason;
|
||||
if (reason) {
|
||||
filter.reason = reason;
|
||||
}
|
||||
|
||||
// Remove the action from the database, returning the action that was deleted.
|
||||
@@ -354,8 +350,10 @@ export const ACTION_COUNT_JOIN_CHAR = "__";
|
||||
*
|
||||
* @param actions list of actions to generate the action counts from
|
||||
*/
|
||||
export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
|
||||
const actionCounts: EncodedActionCounts = {};
|
||||
export function encodeActionCounts(
|
||||
...actions: CommentAction[]
|
||||
): EncodedCommentActionCounts {
|
||||
const actionCounts: EncodedCommentActionCounts = {};
|
||||
|
||||
// Loop over the actions, and increment them.
|
||||
for (const action of actions) {
|
||||
@@ -377,8 +375,8 @@ export function encodeActionCounts(...actions: Action[]): EncodedActionCounts {
|
||||
* @param actionCounts the encoded action counts to invert
|
||||
*/
|
||||
export function invertEncodedActionCounts(
|
||||
actionCounts: EncodedActionCounts
|
||||
): EncodedActionCounts {
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
): EncodedCommentActionCounts {
|
||||
for (const key in actionCounts) {
|
||||
if (!actionCounts.hasOwnProperty(key)) {
|
||||
continue;
|
||||
@@ -396,11 +394,11 @@ export function invertEncodedActionCounts(
|
||||
* 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];
|
||||
function encodeActionCountKeys(action: CommentAction): string[] {
|
||||
const keys = [action.actionType as string];
|
||||
if (action.reason) {
|
||||
keys.push(
|
||||
[action.action_type as string, action.reason as string].join(
|
||||
[action.actionType as string, action.reason as string].join(
|
||||
ACTION_COUNT_JOIN_CHAR
|
||||
)
|
||||
);
|
||||
@@ -496,10 +494,10 @@ function createEmptyActionCounts(): GQLActionCounts {
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeActionCounts(
|
||||
actionCounts: EncodedActionCounts[]
|
||||
): EncodedActionCounts {
|
||||
const mergedActionCounts: EncodedActionCounts = {};
|
||||
export function mergeCommentActionCounts(
|
||||
actionCounts: EncodedCommentActionCounts[]
|
||||
): EncodedCommentActionCounts {
|
||||
const mergedActionCounts: EncodedCommentActionCounts = {};
|
||||
|
||||
for (const counts of actionCounts) {
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
@@ -515,7 +513,7 @@ export function mergeActionCounts(
|
||||
}
|
||||
|
||||
export function countTotalActionCounts(
|
||||
actionCounts: EncodedActionCounts
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
): number {
|
||||
return Object.values(actionCounts).reduce((total, count) => total + count, 0);
|
||||
}
|
||||
@@ -527,7 +525,7 @@ export function countTotalActionCounts(
|
||||
* @param encodedActionCounts the action counts to decode
|
||||
*/
|
||||
export function decodeActionCounts(
|
||||
encodedActionCounts: EncodedActionCounts
|
||||
encodedActionCounts: EncodedCommentActionCounts
|
||||
): GQLActionCounts {
|
||||
// Default all the action counts to zero.
|
||||
const actionCounts: GQLActionCounts = createEmptyActionCounts();
|
||||
@@ -579,37 +577,37 @@ function incrementActionCounts(
|
||||
* removeRootActions will remove all the Action's associated with a given root
|
||||
* identifier.
|
||||
*/
|
||||
export async function removeRootActions(
|
||||
export async function removeStoryActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
rootItemID: string
|
||||
storyID: string
|
||||
) {
|
||||
return collection(mongo).deleteMany({
|
||||
tenant_id: tenantID,
|
||||
root_item_id: rootItemID,
|
||||
tenantID,
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* mergeManyRootActions will update many Action `root_item_id'`s from one to
|
||||
* mergeManyRootActions will update many Action `storyID`'s from one to
|
||||
* another.
|
||||
*/
|
||||
export async function mergeManyRootActions(
|
||||
export async function mergeManyStoryActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
newRootItemID: string,
|
||||
oldRootItemIDs: string[]
|
||||
newStoryID: string,
|
||||
oldStoryIDs: string[]
|
||||
) {
|
||||
return collection(mongo).updateMany(
|
||||
{
|
||||
tenant_id: tenantID,
|
||||
root_item_id: {
|
||||
$in: oldRootItemIDs,
|
||||
tenantID,
|
||||
storyID: {
|
||||
$in: oldStoryIDs,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
root_item_id: newRootItemID,
|
||||
storyID: newStoryID,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
Connection,
|
||||
Cursor,
|
||||
getPageInfo,
|
||||
nodesToEdges,
|
||||
} from "talk-server/models/connection";
|
||||
import Query from "talk-server/models/query";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
function collection(db: Db) {
|
||||
return db.collection<Readonly<CommentModerationAction>>(
|
||||
"commentModerationActions"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommentModerationAction stores information around a moderation action that
|
||||
* was created for a given Comment Revision.
|
||||
*/
|
||||
export interface CommentModerationAction extends TenantResource {
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* commentID is the ID of the Comment that the moderation action is based on.
|
||||
*/
|
||||
commentID: string;
|
||||
|
||||
/**
|
||||
* commentRevisionID is the ID of the Revision that the moderation action is
|
||||
* based on.
|
||||
*/
|
||||
commentRevisionID: string;
|
||||
|
||||
/**
|
||||
* status is the GQLCOMMENT_STATUS assigned by the moderator for this
|
||||
* moderation action.
|
||||
*/
|
||||
status: GQLCOMMENT_STATUS;
|
||||
|
||||
/**
|
||||
* moderatorID is the ID of the User that created the moderation action. If
|
||||
* null, it indicates that it was created by the system rather than a User.
|
||||
*/
|
||||
moderatorID: string | null;
|
||||
|
||||
/**
|
||||
* createdAt is the time that the moderation action was created on.
|
||||
*/
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type CreateCommentModerationActionInput = Omit<
|
||||
CommentModerationAction,
|
||||
"tenantID" | "id" | "createdAt"
|
||||
>;
|
||||
|
||||
export async function createCommentModerationAction(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: CreateCommentModerationActionInput
|
||||
) {
|
||||
// default are the properties set by the application when a new comment
|
||||
// moderation action is created.
|
||||
const defaults: Sub<
|
||||
CommentModerationAction,
|
||||
CreateCommentModerationActionInput
|
||||
> = {
|
||||
id: uuid.v4(),
|
||||
tenantID,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Merge the defaults and the input together.
|
||||
const action: Readonly<CommentModerationAction> = {
|
||||
...defaults,
|
||||
...input,
|
||||
};
|
||||
|
||||
// Insert it into the database.
|
||||
await collection(mongo).insertOne(action);
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
export type CommentModerationActionFilter = Partial<
|
||||
Pick<
|
||||
CommentModerationAction,
|
||||
"commentID" | "commentRevisionID" | "moderatorID" | "status"
|
||||
>
|
||||
>;
|
||||
|
||||
export async function retrieveCommentModerationActions(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
filter: CommentModerationActionFilter
|
||||
) {
|
||||
const result = await collection(mongo).find({
|
||||
tenantID,
|
||||
...filter,
|
||||
});
|
||||
|
||||
return result.toArray();
|
||||
}
|
||||
|
||||
export interface ConnectionInput {
|
||||
first: number;
|
||||
after?: Cursor;
|
||||
filter?: CommentModerationActionFilter;
|
||||
}
|
||||
|
||||
export async function retrieveCommentModerationActionConnection(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
input: ConnectionInput
|
||||
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
|
||||
// Create the query.
|
||||
const query = new Query(collection(mongo)).where({ tenantID });
|
||||
|
||||
// If a filter is being applied, filter it as well.
|
||||
if (input.filter) {
|
||||
query.where(input.filter);
|
||||
}
|
||||
|
||||
return retrieveConnection(input, query);
|
||||
}
|
||||
|
||||
async function retrieveConnection(
|
||||
input: ConnectionInput,
|
||||
query: Query<CommentModerationAction>
|
||||
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
|
||||
// Apply the cursor to the query. Currently only supporting sorting by the
|
||||
// newest first.
|
||||
query.orderBy({ createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.where({ createdAt: { $lt: input.after as Date } });
|
||||
}
|
||||
|
||||
// We load one more than the limit so we can determine if there is
|
||||
// another page of entries. This gets trimmed off below after we've checked to
|
||||
// see if this constitutes another page of edges.
|
||||
query.first(input.first + 1);
|
||||
|
||||
// Get the cursor.
|
||||
const cursor = await query.exec();
|
||||
|
||||
// Get the comments from the cursor.
|
||||
const nodes = await cursor.toArray();
|
||||
|
||||
// Convert the nodes to edges (which will include the extra edge we don't need
|
||||
// if there is more results).
|
||||
const edges = nodesToEdges(nodes, a => a.createdAt);
|
||||
|
||||
// Get the pageInfo for the connection. We will use this to also determine if
|
||||
// we need to trim off the extra edge that we requested by comparing its
|
||||
// hasNextPage parameter.
|
||||
const pageInfo = getPageInfo(input, edges);
|
||||
if (pageInfo.hasNextPage) {
|
||||
// Because this means that we got one more than expected, we should trim off
|
||||
// the extra edge that was retrieved.
|
||||
edges.splice(input.first, 1);
|
||||
}
|
||||
|
||||
// Return the connection.
|
||||
return {
|
||||
edges,
|
||||
pageInfo,
|
||||
};
|
||||
}
|
||||
+256
-106
@@ -7,7 +7,7 @@ import {
|
||||
GQLCOMMENT_SORT,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { EncodedActionCounts } from "talk-server/models/action";
|
||||
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
|
||||
import {
|
||||
Connection,
|
||||
createConnection,
|
||||
@@ -23,82 +23,156 @@ function collection(db: Db) {
|
||||
return db.collection<Readonly<Comment>>("comments");
|
||||
}
|
||||
|
||||
export interface BodyHistoryItem {
|
||||
body: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface StatusHistoryItem {
|
||||
status: GQLCOMMENT_STATUS;
|
||||
assigned_by?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Comment extends TenantResource {
|
||||
/**
|
||||
* Revision stores a Comment's body for a specific edit. Actions can be tied to
|
||||
* a Revision, as can moderation actions.
|
||||
*/
|
||||
export interface Revision {
|
||||
/**
|
||||
* id identifies this Revision.
|
||||
*/
|
||||
readonly id: string;
|
||||
parent_id?: string;
|
||||
author_id: string;
|
||||
story_id: string;
|
||||
|
||||
/**
|
||||
* body is the body text for this revision.
|
||||
*/
|
||||
body: string;
|
||||
body_history: BodyHistoryItem[];
|
||||
|
||||
/**
|
||||
* actionCounts is the cached action counts on this revision.
|
||||
*/
|
||||
actionCounts: EncodedCommentActionCounts;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this revision was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment's are created by User's on Stories. Each Comment contains a body, and
|
||||
* can be moderated by another Moderator or Admin User.
|
||||
*/
|
||||
export interface Comment extends TenantResource {
|
||||
/**
|
||||
* id identifies this Comment specifically.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* parentID stores the ID of a parent Comment if this Comment is a reply.
|
||||
*/
|
||||
parentID?: string;
|
||||
|
||||
/**
|
||||
* authorID stores the ID of the User that created this Comment.
|
||||
*/
|
||||
authorID: string;
|
||||
|
||||
/**
|
||||
* storyID stores the ID of the Story that this Comment was left on.
|
||||
*/
|
||||
storyID: string;
|
||||
|
||||
/**
|
||||
* revisions stores all the revisions of the Comment body including the most
|
||||
* recent revision, the last revision is the most recent.
|
||||
*/
|
||||
revisions: Revision[];
|
||||
|
||||
/**
|
||||
* status is the current Comment Status.
|
||||
*/
|
||||
status: GQLCOMMENT_STATUS;
|
||||
status_history: StatusHistoryItem[];
|
||||
action_counts: EncodedActionCounts;
|
||||
grandparent_ids: string[];
|
||||
reply_ids: string[];
|
||||
reply_count: number;
|
||||
created_at: Date;
|
||||
deleted_at?: Date;
|
||||
|
||||
/**
|
||||
* actionCounts stores a cached count of all the Action's against this
|
||||
* Comment.
|
||||
*/
|
||||
actionCounts: EncodedCommentActionCounts;
|
||||
|
||||
/**
|
||||
* grandparentIDs stores all the ID's of all the Comment's that came before.
|
||||
* This prevents the need for performing multiple queries to retrieve the
|
||||
* Comment ancestors.
|
||||
*/
|
||||
grandparentIDs: string[];
|
||||
|
||||
/**
|
||||
* replyIDs are the ID's of all the Comment's that are direct replies.
|
||||
*/
|
||||
replyIDs: string[];
|
||||
|
||||
/**
|
||||
* replyCount is the count of direct replies. It is stored as a separate value
|
||||
* here even though the replyIDs field technically contained the same data in
|
||||
* it's length because we needed to sort by this field sometimes.
|
||||
*/
|
||||
replyCount: number;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this Comment was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* deletedAt is the date that this Comment was deleted on. If null or
|
||||
* undefined, this Comment is not deleted.
|
||||
*/
|
||||
deletedAt?: Date;
|
||||
|
||||
/**
|
||||
* metadata stores the deep Comment properties.
|
||||
*/
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type CreateCommentInput = Omit<
|
||||
Comment,
|
||||
| "id"
|
||||
| "tenant_id"
|
||||
| "created_at"
|
||||
| "reply_ids"
|
||||
| "reply_count"
|
||||
| "body_history"
|
||||
| "status_history"
|
||||
>;
|
||||
| "tenantID"
|
||||
| "createdAt"
|
||||
| "replyIDs"
|
||||
| "replyCount"
|
||||
| "actionCounts"
|
||||
| "revisions"
|
||||
> &
|
||||
Required<Pick<Revision, "body">>;
|
||||
|
||||
export async function createComment(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
input: CreateCommentInput
|
||||
) {
|
||||
const now = new Date();
|
||||
const createdAt = new Date();
|
||||
|
||||
// Pull out some useful properties from the input.
|
||||
const { body, status } = input;
|
||||
const { body, ...rest } = input;
|
||||
|
||||
// Generate the revision.
|
||||
const revision: Revision = {
|
||||
id: uuid.v4(),
|
||||
body,
|
||||
actionCounts: {},
|
||||
createdAt,
|
||||
};
|
||||
|
||||
// default are the properties set by the application when a new comment is
|
||||
// created.
|
||||
const defaults: Sub<Comment, CreateCommentInput> = {
|
||||
id: uuid.v4(),
|
||||
tenant_id: tenantID,
|
||||
created_at: now,
|
||||
reply_ids: [],
|
||||
reply_count: 0,
|
||||
body_history: [
|
||||
{
|
||||
body,
|
||||
created_at: now,
|
||||
},
|
||||
],
|
||||
status_history: [
|
||||
{
|
||||
status,
|
||||
created_at: now,
|
||||
},
|
||||
],
|
||||
tenantID,
|
||||
createdAt,
|
||||
replyIDs: [],
|
||||
replyCount: 0,
|
||||
actionCounts: {},
|
||||
revisions: [revision],
|
||||
};
|
||||
|
||||
// Merge the defaults and the input together.
|
||||
const comment: Readonly<Comment> = {
|
||||
...defaults,
|
||||
...input,
|
||||
...rest,
|
||||
};
|
||||
|
||||
// Insert it into the database.
|
||||
@@ -120,12 +194,12 @@ export async function pushChildCommentIDOntoParent(
|
||||
// This pushes the new child ID onto the parent comment.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
id: parentID,
|
||||
},
|
||||
{
|
||||
$push: { reply_ids: childID },
|
||||
$inc: { reply_count: 1 },
|
||||
$push: { replyIDs: childID },
|
||||
$inc: { replyCount: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
@@ -134,7 +208,7 @@ export async function pushChildCommentIDOntoParent(
|
||||
|
||||
export type EditCommentInput = Pick<
|
||||
Comment,
|
||||
"id" | "author_id" | "body" | "status" | "metadata"
|
||||
"id" | "authorID" | "status" | "metadata"
|
||||
> & {
|
||||
/**
|
||||
* lastEditableCommentCreatedAt is the date that the last comment would have
|
||||
@@ -142,18 +216,22 @@ export type EditCommentInput = Pick<
|
||||
* `editCommentWindowLength` property.
|
||||
*/
|
||||
lastEditableCommentCreatedAt: Date;
|
||||
};
|
||||
} & Required<Pick<Revision, "body">>;
|
||||
|
||||
export async function editComment(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
input: EditCommentInput
|
||||
) {
|
||||
// TODO: (wyattjoh) now that we have revisions, do we really have this restriction?
|
||||
|
||||
// Only comments with the following status's can be edited.
|
||||
const EDITABLE_STATUSES = [
|
||||
GQLCOMMENT_STATUS.NONE,
|
||||
GQLCOMMENT_STATUS.PREMOD,
|
||||
GQLCOMMENT_STATUS.ACCEPTED,
|
||||
];
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
const {
|
||||
@@ -161,28 +239,33 @@ export async function editComment(
|
||||
body,
|
||||
lastEditableCommentCreatedAt,
|
||||
status,
|
||||
author_id,
|
||||
authorID,
|
||||
metadata,
|
||||
} = input;
|
||||
|
||||
// TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment
|
||||
// Generate the revision.
|
||||
const revision: Revision = {
|
||||
id: uuid.v4(),
|
||||
body,
|
||||
actionCounts: {},
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
author_id,
|
||||
tenantID,
|
||||
authorID,
|
||||
status: {
|
||||
$in: EDITABLE_STATUSES,
|
||||
},
|
||||
deleted_at: null,
|
||||
created_at: {
|
||||
deletedAt: null,
|
||||
createdAt: {
|
||||
$gt: lastEditableCommentCreatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
body,
|
||||
status,
|
||||
// Embed all the metadata properties, this may override the existing
|
||||
// metadata, but we won't replace metadata that has been recalculated.
|
||||
@@ -190,14 +273,7 @@ export async function editComment(
|
||||
...dotize({ metadata }),
|
||||
},
|
||||
$push: {
|
||||
body_history: {
|
||||
body,
|
||||
created_at: createdAt,
|
||||
},
|
||||
status_history: {
|
||||
type: status,
|
||||
created_at: createdAt,
|
||||
},
|
||||
revisions: revision,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
@@ -212,7 +288,7 @@ export async function editComment(
|
||||
throw new Error("comment not found");
|
||||
}
|
||||
|
||||
if (comment.author_id !== author_id) {
|
||||
if (comment.authorID !== authorID) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("comment author mismatch");
|
||||
}
|
||||
@@ -224,7 +300,7 @@ export async function editComment(
|
||||
}
|
||||
|
||||
// Check to see if the edit window expired.
|
||||
if (comment.created_at <= lastEditableCommentCreatedAt) {
|
||||
if (comment.createdAt <= lastEditableCommentCreatedAt) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("edit window expired");
|
||||
}
|
||||
@@ -237,7 +313,7 @@ export async function editComment(
|
||||
}
|
||||
|
||||
export async function retrieveComment(db: Db, tenantID: string, id: string) {
|
||||
return collection(db).findOne({ id, tenant_id: tenantID });
|
||||
return collection(db).findOne({ id, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveManyComments(
|
||||
@@ -249,7 +325,7 @@ export async function retrieveManyComments(
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const comments = await cursor.toArray();
|
||||
@@ -269,7 +345,7 @@ function cursorGetterFactory(
|
||||
switch (input.orderBy) {
|
||||
case GQLCOMMENT_SORT.CREATED_AT_DESC:
|
||||
case GQLCOMMENT_SORT.CREATED_AT_ASC:
|
||||
return comment => comment.created_at;
|
||||
return comment => comment.createdAt;
|
||||
case GQLCOMMENT_SORT.REPLIES_DESC:
|
||||
case GQLCOMMENT_SORT.RESPECT_DESC:
|
||||
return (_, index) =>
|
||||
@@ -294,9 +370,9 @@ export async function retrieveCommentRepliesConnection(
|
||||
) {
|
||||
// Create the query.
|
||||
const query = new Query(collection(db)).where({
|
||||
tenant_id: tenantID,
|
||||
story_id: storyID,
|
||||
parent_id: parentID,
|
||||
tenantID,
|
||||
storyID,
|
||||
parentID,
|
||||
});
|
||||
|
||||
// Return a connection for the comments query.
|
||||
@@ -319,7 +395,7 @@ export async function retrieveCommentParentsConnection(
|
||||
{ last: limit, before: skip = 0 }: { last: number; before?: number }
|
||||
): Promise<Readonly<Connection<Readonly<Comment>>>> {
|
||||
// Return nothing if this comment does not have any parents.
|
||||
if (!comment.parent_id) {
|
||||
if (!comment.parentID) {
|
||||
return createConnection({
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
@@ -334,7 +410,7 @@ export async function retrieveCommentParentsConnection(
|
||||
return createConnection({
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: !!comment.parent_id,
|
||||
hasPreviousPage: !!comment.parentID,
|
||||
endCursor: 0,
|
||||
startCursor: 0,
|
||||
},
|
||||
@@ -344,7 +420,7 @@ export async function retrieveCommentParentsConnection(
|
||||
// If the last paramter is 1, and the after paramter is either unset or equal
|
||||
// to zero, then all we have to return is the direct parent.
|
||||
if (limit === 1 && skip <= 0) {
|
||||
const parent = await retrieveComment(mongo, tenantID, comment.parent_id);
|
||||
const parent = await retrieveComment(mongo, tenantID, comment.parentID);
|
||||
if (!parent) {
|
||||
throw new Error("parent comment not found");
|
||||
}
|
||||
@@ -353,7 +429,7 @@ export async function retrieveCommentParentsConnection(
|
||||
edges: [{ node: parent, cursor: 1 }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: comment.grandparent_ids.length > 0,
|
||||
hasPreviousPage: comment.grandparentIDs.length > 0,
|
||||
endCursor: 1,
|
||||
startCursor: 1,
|
||||
},
|
||||
@@ -361,7 +437,7 @@ export async function retrieveCommentParentsConnection(
|
||||
}
|
||||
|
||||
// Create a list of all the comment parent ids, in reverse order.
|
||||
const parentIDs = [comment.parent_id, ...comment.grandparent_ids.reverse()];
|
||||
const parentIDs = [comment.parentID, ...comment.grandparentIDs.reverse()];
|
||||
|
||||
// Fetch the subset of the comment id's that we are going to query for.
|
||||
const parentIDSubset = parentIDs.slice(skip, skip + limit);
|
||||
@@ -415,9 +491,15 @@ export async function retrieveCommentStoryConnection(
|
||||
) {
|
||||
// Create the query.
|
||||
const query = new Query(collection(db)).where({
|
||||
tenant_id: tenantID,
|
||||
story_id: storyID,
|
||||
parent_id: null,
|
||||
tenantID,
|
||||
storyID,
|
||||
// Only get Comments that are top level. If the client wants to load another
|
||||
// layer, they can request another nested connection.
|
||||
parentID: null,
|
||||
// Only get Comment's that are visible.
|
||||
status: {
|
||||
$in: [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.ACCEPTED],
|
||||
},
|
||||
});
|
||||
|
||||
// Return a connection for the comments query.
|
||||
@@ -440,8 +522,8 @@ export async function retrieveCommentUserConnection(
|
||||
) {
|
||||
// Create the query.
|
||||
const query = new Query(collection(db)).where({
|
||||
tenant_id: tenantID,
|
||||
author_id: userID,
|
||||
tenantID,
|
||||
authorID: userID,
|
||||
});
|
||||
|
||||
// Return a connection for the comments query.
|
||||
@@ -498,25 +580,25 @@ async function retrieveConnection(
|
||||
function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
|
||||
switch (input.orderBy) {
|
||||
case GQLCOMMENT_SORT.CREATED_AT_DESC:
|
||||
query.orderBy({ created_at: -1 });
|
||||
query.orderBy({ createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.where({ created_at: { $lt: input.after as Date } });
|
||||
query.where({ createdAt: { $lt: input.after as Date } });
|
||||
}
|
||||
break;
|
||||
case GQLCOMMENT_SORT.CREATED_AT_ASC:
|
||||
query.orderBy({ created_at: 1 });
|
||||
query.orderBy({ createdAt: 1 });
|
||||
if (input.after) {
|
||||
query.where({ created_at: { $gt: input.after as Date } });
|
||||
query.where({ createdAt: { $gt: input.after as Date } });
|
||||
}
|
||||
break;
|
||||
case GQLCOMMENT_SORT.REPLIES_DESC:
|
||||
query.orderBy({ reply_count: -1, created_at: -1 });
|
||||
query.orderBy({ replyCount: -1, createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.after(input.after as number);
|
||||
}
|
||||
break;
|
||||
case GQLCOMMENT_SORT.RESPECT_DESC:
|
||||
query.orderBy({ "action_counts.REACTION": -1, created_at: -1 });
|
||||
query.orderBy({ "actionCounts.REACTION": -1, createdAt: -1 });
|
||||
if (input.after) {
|
||||
query.after(input.after as number);
|
||||
}
|
||||
@@ -524,6 +606,52 @@ function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateCommentStatus {
|
||||
comment: Readonly<Comment>;
|
||||
oldStatus: GQLCOMMENT_STATUS;
|
||||
}
|
||||
|
||||
export async function updateCommentStatus(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
revisionID: string,
|
||||
status: GQLCOMMENT_STATUS
|
||||
): Promise<UpdateCommentStatus | null> {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
"revisions.id": revisionID,
|
||||
status: {
|
||||
$ne: status,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: { status },
|
||||
},
|
||||
{
|
||||
// True to return the original document instead of the updated
|
||||
// document.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grab the old status.
|
||||
const oldStatus = result.value.status;
|
||||
|
||||
return {
|
||||
comment: {
|
||||
...result.value,
|
||||
status,
|
||||
},
|
||||
oldStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* updateCommentActionCounts will update the given comment's action counts.
|
||||
*
|
||||
@@ -536,19 +664,30 @@ export async function updateCommentActionCounts(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
actionCounts: EncodedActionCounts
|
||||
revisionID: string,
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenant_id: tenantID },
|
||||
{ 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 }
|
||||
{
|
||||
$inc: dotize({
|
||||
actionCounts,
|
||||
"revisions.$[revision]": { actionCounts },
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Add an ArrayFilter to only update one of the OpenID Connect
|
||||
// integrations.
|
||||
arrayFilters: [{ "revision.id": revisionID }],
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value;
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,8 +701,8 @@ export async function removeStoryComments(
|
||||
) {
|
||||
// Delete all the comments written on a specific story.
|
||||
return collection(mongo).deleteMany({
|
||||
tenant_id: tenantID,
|
||||
story_id: storyID,
|
||||
tenantID,
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -578,15 +717,26 @@ export async function mergeManyCommentStories(
|
||||
) {
|
||||
return collection(mongo).updateMany(
|
||||
{
|
||||
tenant_id: tenantID,
|
||||
story_id: {
|
||||
tenantID,
|
||||
storyID: {
|
||||
$in: oldStoryIDs,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
story_id: newStoryID,
|
||||
storyID: newStoryID,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* getLatestRevision will get the latest revision from a Comment.
|
||||
*
|
||||
* @param comment the comment that contains the revisions
|
||||
*/
|
||||
export function getLatestRevision(
|
||||
comment: Pick<Comment, "revisions">
|
||||
): Revision {
|
||||
return comment.revisions[comment.revisions.length - 1];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLStoryMetadata,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { EncodedActionCounts } from "talk-server/models/action";
|
||||
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
@@ -43,15 +43,15 @@ export interface Story extends TenantResource {
|
||||
scrapedAt?: Date;
|
||||
|
||||
/**
|
||||
* action_counts stores all the action counts for all Comment's on this Story.
|
||||
* actionCounts stores all the action counts for all Comment's on this Story.
|
||||
*/
|
||||
action_counts: EncodedActionCounts;
|
||||
commentActionCounts: EncodedCommentActionCounts;
|
||||
|
||||
/**
|
||||
* comment_counts stores the different counts for each comment on the Story
|
||||
* commentCounts stores the different counts for each comment on the Story
|
||||
* according to their statuses.
|
||||
*/
|
||||
comment_counts: CommentStatusCounts;
|
||||
commentCounts: CommentStatusCounts;
|
||||
|
||||
/**
|
||||
* settings provides a point where the settings can be overridden for a
|
||||
@@ -66,9 +66,9 @@ export interface Story extends TenantResource {
|
||||
closedAt?: Date | false;
|
||||
|
||||
/**
|
||||
* created_at is the date that the Story was added to the Talk database.
|
||||
* createdAt is the date that the Story was added to the Talk database.
|
||||
*/
|
||||
created_at: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UpsertStoryInput {
|
||||
@@ -84,15 +84,15 @@ export async function upsertStory(
|
||||
const now = new Date();
|
||||
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenant_id.
|
||||
// porting in the tenantID.
|
||||
const update: { $setOnInsert: Story } = {
|
||||
$setOnInsert: {
|
||||
id: id ? id : uuid.v4(),
|
||||
url,
|
||||
tenant_id: tenantID,
|
||||
created_at: now,
|
||||
action_counts: {},
|
||||
comment_counts: createEmptyCommentCounts(),
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentActionCounts: {},
|
||||
commentCounts: createEmptyCommentCounts(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function upsertStory(
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{
|
||||
url,
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
},
|
||||
update,
|
||||
{
|
||||
@@ -136,11 +136,11 @@ export async function updateCommentStatusCount(
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
},
|
||||
// Update all the specific comment status counts that are associated with
|
||||
// each of the counts.
|
||||
{ $inc: dotize({ comment_counts: commentStatusCounts }) },
|
||||
{ $inc: dotize({ commentCounts: commentStatusCounts }) },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
@@ -238,10 +238,10 @@ export async function createStory(
|
||||
...input,
|
||||
id,
|
||||
url,
|
||||
tenant_id: tenantID,
|
||||
created_at: now,
|
||||
action_counts: {},
|
||||
comment_counts: createEmptyCommentCounts(),
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentActionCounts: {},
|
||||
commentCounts: createEmptyCommentCounts(),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -267,11 +267,11 @@ export async function retrieveStoryByURL(
|
||||
tenantID: string,
|
||||
url: string
|
||||
) {
|
||||
return collection(db).findOne({ url, tenant_id: tenantID });
|
||||
return collection(db).findOne({ url, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveStory(db: Db, tenantID: string, id: string) {
|
||||
return collection(db).findOne({ id, tenant_id: tenantID });
|
||||
return collection(db).findOne({ id, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveManyStories(
|
||||
@@ -281,7 +281,7 @@ export async function retrieveManyStories(
|
||||
) {
|
||||
const cursor = await collection(db).find({
|
||||
id: { $in: ids },
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
@@ -296,7 +296,7 @@ export async function retrieveManyStoriesByURL(
|
||||
) {
|
||||
const cursor = await collection(db).find({
|
||||
url: { $in: urls },
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const stories = await cursor.toArray();
|
||||
@@ -306,7 +306,7 @@ export async function retrieveManyStoriesByURL(
|
||||
|
||||
export type UpdateStoryInput = Omit<
|
||||
Partial<Story>,
|
||||
"id" | "tenant_id" | "created_at"
|
||||
"id" | "tenantID" | "createdAt"
|
||||
>;
|
||||
|
||||
export async function updateStory(
|
||||
@@ -320,13 +320,13 @@ export async function updateStory(
|
||||
$set: {
|
||||
...dotize(input, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updated_at: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{ id, tenant_id: tenantID },
|
||||
{ id, tenantID },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
@@ -359,13 +359,13 @@ export async function updateStoryActionCounts(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
actionCounts: EncodedActionCounts
|
||||
actionCounts: EncodedCommentActionCounts
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenant_id: tenantID },
|
||||
{ id, tenantID },
|
||||
// Update all the specific action counts that are associated with each of
|
||||
// the counts.
|
||||
{ $inc: dotize({ action_counts: actionCounts }) },
|
||||
{ $inc: dotize({ actionCounts }) },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
@@ -377,7 +377,7 @@ export async function updateStoryActionCounts(
|
||||
export async function removeStory(mongo: Db, tenantID: string, id: string) {
|
||||
const result = await collection(mongo).findOneAndDelete({
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
return result.value || null;
|
||||
@@ -392,7 +392,7 @@ export async function removeStories(
|
||||
ids: string[]
|
||||
) {
|
||||
return collection(mongo).deleteMany({
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ function dotizeDropNull(o: Record<string, any>, options?: DotizeOptions) {
|
||||
}
|
||||
|
||||
export interface TenantResource {
|
||||
readonly tenant_id: string;
|
||||
/**
|
||||
* tenantID is the reference to the specific Tenant that owns this particular
|
||||
* resource.
|
||||
*/
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_USERNAME_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { EncodedActionCounts } from "talk-server/models/action";
|
||||
import { FilterQuery } from "talk-server/models/query";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
@@ -57,9 +56,9 @@ export interface Token {
|
||||
|
||||
export interface UserStatusHistory<T> {
|
||||
status: T;
|
||||
assigned_by?: string;
|
||||
assignedBy?: string;
|
||||
reason?: string;
|
||||
created_at: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UserStatusItem<T> {
|
||||
@@ -80,25 +79,18 @@ export interface User extends TenantResource {
|
||||
password?: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
emailVerified?: boolean;
|
||||
profiles: Profile[];
|
||||
tokens: Token[];
|
||||
role: GQLUSER_ROLE;
|
||||
status: UserStatus;
|
||||
action_counts: EncodedActionCounts;
|
||||
ignored_users: string[];
|
||||
created_at: Date;
|
||||
ignoredUserIDs: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type UpsertUserInput = Omit<
|
||||
User,
|
||||
| "id"
|
||||
| "tenant_id"
|
||||
| "tokens"
|
||||
| "status"
|
||||
| "action_counts"
|
||||
| "ignored_users"
|
||||
| "created_at"
|
||||
"id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt"
|
||||
>;
|
||||
|
||||
export async function upsertUser(
|
||||
@@ -115,10 +107,9 @@ export async function upsertUser(
|
||||
// created.
|
||||
const defaults: Sub<User, UpsertUserInput> = {
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
tokens: [],
|
||||
action_counts: {},
|
||||
ignored_users: [],
|
||||
ignoredUserIDs: [],
|
||||
status: {
|
||||
banned: {
|
||||
status: false,
|
||||
@@ -135,7 +126,7 @@ export async function upsertUser(
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
let hashedPassword;
|
||||
@@ -202,7 +193,7 @@ const createUpsertUserFilter = (user: Readonly<User>) => {
|
||||
};
|
||||
|
||||
export async function retrieveUser(db: Db, tenantID: string, id: string) {
|
||||
return collection(db).findOne({ id, tenant_id: tenantID });
|
||||
return collection(db).findOne({ id, tenantID });
|
||||
}
|
||||
|
||||
export async function retrieveManyUsers(
|
||||
@@ -214,7 +205,7 @@ export async function retrieveManyUsers(
|
||||
id: {
|
||||
$in: ids,
|
||||
},
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
});
|
||||
|
||||
const users = await cursor.toArray();
|
||||
@@ -228,7 +219,7 @@ export async function retrieveUserWithProfile(
|
||||
profile: Profile
|
||||
) {
|
||||
return collection(db).findOne({
|
||||
tenant_id: tenantID,
|
||||
tenantID,
|
||||
profiles: {
|
||||
$elemMatch: profile,
|
||||
},
|
||||
@@ -242,7 +233,7 @@ export async function updateUserRole(
|
||||
role: GQLUSER_ROLE
|
||||
) {
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{ id, tenant_id: tenantID },
|
||||
{ id, tenantID },
|
||||
{ $set: { role } },
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user