* Fix various Q&A moderation/tagging issues

- Allow Staff members to have their questions
answered and appropriately tagged
- Properly filter answering for only top level
questions (comments)
- Add documentation around various moderation
phases and comment creation steps
- Remove unnecessary status filter when setting
the status for a comment

* fix: abstracted out perspective configs

* fix: reworked tag injection

* Fix sorting/unused imports

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
This commit is contained in:
Nick Funk
2020-02-27 14:40:01 -07:00
committed by GitHub
parent c052d37a6f
commit 769ab2a910
22 changed files with 393 additions and 399 deletions
@@ -0,0 +1,5 @@
export * from "./notifier";
export * from "./perspective";
export * from "./slack";
export * from "./subscription";
export * from "./webhook";
@@ -0,0 +1,121 @@
import striptags from "striptags";
import { reconstructTenantURL } from "coral-server/app/url";
import { sendToPerspective } from "coral-server/services/perspective";
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
import { CommentStatusUpdatedCoralEventPayload } from "../events";
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
import { CoralEventType } from "../types";
type PerspectiveCoralEventListenerPayloads = CommentStatusUpdatedCoralEventPayload;
export class PerspectiveCoralEventListener
implements CoralEventListener<PerspectiveCoralEventListenerPayloads> {
public readonly name = "perspective";
public readonly events = [CoralEventType.COMMENT_STATUS_UPDATED];
private computeStatus(status: GQLCOMMENT_STATUS) {
if (status === GQLCOMMENT_STATUS.APPROVED) {
return "APPROVED";
}
if (status === GQLCOMMENT_STATUS.REJECTED) {
return "DELETED";
}
return null;
}
public initialize: CoralEventPublisherFactory<
PerspectiveCoralEventListenerPayloads
> = ctx => async ({ data: { newStatus, commentID, commentRevisionID } }) => {
const {
tenant: {
integrations: { perspective },
},
} = ctx;
if (
// If perspective isn't enabled,
!perspective.enabled ||
// Or the key isn't provided
!perspective.key ||
// Or the feedback sending option is disabled
!perspective.sendFeedback
) {
// Exit out then.
return;
}
// Compute the status being sent to perspective.
const status = this.computeStatus(newStatus);
if (!status) {
// The new status was not associated with
return;
}
try {
// Get the comment in question.
const comment = await ctx.loaders.Comments.comment.load(commentID);
if (!comment) {
return;
}
// Get the target revision.
const revision = comment.revisions.find(r => r.id === commentRevisionID);
if (!revision) {
return;
}
// Get the story for this comment.
const story = await ctx.loaders.Stories.story.load(comment.storyID);
if (!story) {
return;
}
// Reconstruct the Tenant URL.
const tenantURL = reconstructTenantURL(ctx.config, ctx.tenant);
// This typecast is needed because the custom `ms` format does not return the
// desired `number` type even though that's the only type it can output.
const timeout = (ctx.config.get(
"perspective_timeout"
) as unknown) as number;
// Get the response from perspective.
const result = await sendToPerspective(
{ key: perspective.key, endpoint: perspective.endpoint, timeout },
{
operation: "comments:suggestscore",
locale: ctx.tenant.locale,
body: {
text: striptags(revision.body),
commentID: comment.id,
commentParentID: comment.parentID,
commentStatus: status,
storyURL: story.url,
tenantURL,
},
}
);
if (result.ok) {
ctx.logger.debug(
{ commentID: comment.id, commentRevisionID },
"successfully sent moderation feedback to perspective"
);
} else {
ctx.logger.error(
{ status: result.status },
"unable to send moderation feedback to perspective"
);
}
} catch (err) {
ctx.logger.error(
{ err },
"unable to send moderation feedback to perspective"
);
}
};
}
@@ -11,7 +11,6 @@ export const Actions = (ctx: GraphContext) => ({
approveComment(
ctx.mongo,
ctx.redis,
ctx.config,
ctx.broker,
ctx.tenant,
input.commentID,
@@ -23,7 +22,6 @@ export const Actions = (ctx: GraphContext) => ({
rejectComment(
ctx.mongo,
ctx.redis,
ctx.config,
ctx.broker,
ctx.tenant,
input.commentID,
@@ -173,7 +173,6 @@ export const Comments = (ctx: GraphContext) => ({
? approveComment(
ctx.mongo,
ctx.redis,
ctx.config,
ctx.broker,
ctx.tenant,
commentID,
@@ -15,6 +15,7 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload {
oldStatus: GQLCOMMENT_STATUS;
moderatorID: string | null;
commentID: string;
commentRevisionID: string;
}
export type CommentStatusUpdatedSubscription = SubscriptionType<
+8 -4
View File
@@ -37,10 +37,13 @@ import {
} from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { NotifierCoralEventListener } from "./events/listeners/notifier";
import { SlackCoralEventListener } from "./events/listeners/slack";
import { SubscriptionCoralEventListener } from "./events/listeners/subscription";
import { WebhookCoralEventListener } from "./events/listeners/webhook";
import {
NotifierCoralEventListener,
PerspectiveCoralEventListener,
SlackCoralEventListener,
SubscriptionCoralEventListener,
WebhookCoralEventListener,
} from "./events/listeners";
import CoralEventListenerBroker from "./events/publisher";
import { isInstalled } from "./services/tenant";
@@ -219,6 +222,7 @@ class Server {
this.broker.register(new SlackCoralEventListener());
this.broker.register(new SubscriptionCoralEventListener());
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
this.broker.register(new PerspectiveCoralEventListener());
// Setup the metrics collectors.
collectDefaultMetrics({ timeout: 5000 });
+2 -5
View File
@@ -152,7 +152,7 @@ export async function createComment(
const { body, actionCounts = {}, metadata, ...rest } = input;
// Generate the revision.
const revision: Revision = {
const revision: Readonly<Revision> = {
id: uuid.v4(),
body,
actionCounts,
@@ -184,7 +184,7 @@ export async function createComment(
// Insert it into the database.
await collection(mongo).insertOne(comment);
return comment;
return { comment, revision };
}
/**
@@ -734,9 +734,6 @@ export async function updateCommentStatus(
id,
tenantID,
"revisions.id": revisionID,
status: {
$ne: status,
},
},
{
$set: { status },
-4
View File
@@ -2,7 +2,6 @@ import Queue, { Job } from "bull";
import { Db } from "mongodb";
import now from "performance-now";
import { Config } from "coral-server/config";
import logger from "coral-server/logger";
import {
Comment,
@@ -22,7 +21,6 @@ const JOB_NAME = "rejector";
export interface RejectorProcessorOptions {
mongo: Db;
redis: AugmentedRedis;
config: Config;
tenantCache: TenantCache;
}
@@ -49,7 +47,6 @@ const createJobProcessor = ({
mongo,
redis,
tenantCache,
config,
}: RejectorProcessorOptions) => async (job: Job<RejectorData>) => {
// Pull out the job data.
const { authorID, moderatorID, tenantID } = job.data;
@@ -85,7 +82,6 @@ const createJobProcessor = ({
await rejectComment(
mongo,
redis,
config,
null,
tenant,
comment.id,
@@ -120,6 +120,7 @@ async function addCommentAction(
...counts,
before: oldComment,
after: updatedComment,
commentRevisionID: input.commentRevisionID,
});
return updatedComment;
@@ -195,6 +196,7 @@ export async function removeCommentAction(
...counts,
before: oldComment,
after: updatedComment,
commentRevisionID,
});
return updatedComment;
@@ -1,4 +1,3 @@
import { CommentTag } from "coral-server/models/comment/tag";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -9,19 +8,9 @@ import {
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
interface Result {
status?: GQLCOMMENT_STATUS;
tags: CommentTag[];
}
export const approve: IntermediateModerationPhase = ({
tags,
now,
}): IntermediatePhaseResult | void => {
const result: Result = {
tags: [],
};
// If the user is tagged STAFF or EXPERT then we approve
// their comment.
//
@@ -34,8 +23,8 @@ export const approve: IntermediateModerationPhase = ({
if (
tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
) {
result.status = GQLCOMMENT_STATUS.APPROVED;
return {
status: GQLCOMMENT_STATUS.APPROVED,
};
}
return result;
};
@@ -1,18 +1,17 @@
import {
GQLTAG,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
import { hasStaffRole } from "coral-server/models/user/helpers";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import { GQLTAG } from "coral-server/graph/schema/__generated__/types";
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
author,
now,
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
if (hasStaffRole(author)) {
return {
tags: [
{
@@ -1,3 +1,4 @@
import { CommentTag } from "coral-server/models/comment/tag";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -12,23 +13,33 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({
author,
now,
story,
comment,
}): IntermediatePhaseResult | void => {
if (
// If we're in Q&A mode...
story.settings.mode === GQLSTORY_MODE.QA &&
// And we have experts for this story...
story.settings.expertIDs &&
// And the author is in expert list...
story.settings.expertIDs.some(id => id === author.id)
) {
return {
tags: [
{
type: GQLTAG.EXPERT,
createdAt: now,
},
{
type: GQLTAG.FEATURED,
createdAt: now,
},
],
};
// Assign this comment an expert tag!
const tags: CommentTag[] = [
{
type: GQLTAG.EXPERT,
createdAt: now,
},
];
// If this comment is the first reply in a thread (depth of 1)...
if (comment.ancestorIDs.length === 1) {
// Add the featured tag!
tags.push({
type: GQLTAG.FEATURED,
createdAt: now,
});
}
return { tags };
}
};
@@ -1,14 +1,9 @@
import { isNil } from "lodash";
import path from "path";
import { URL } from "url";
import {
TOXICITY_ENDPOINT_DEFAULT,
TOXICITY_MODEL_DEFAULT,
TOXICITY_THRESHOLD_DEFAULT,
} from "coral-common/constants";
import { LanguageCode } from "coral-common/helpers";
import { Omit } from "coral-common/types";
import { ToxicCommentError } from "coral-server/errors";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import { hasFeatureFlag } from "coral-server/models/tenant";
@@ -17,20 +12,14 @@ import {
IntermediatePhaseResult,
ModerationPhaseContext,
} from "coral-server/services/comments/pipeline";
import { createFetch } from "coral-server/services/fetch";
import { sendToPerspective } from "coral-server/services/perspective";
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
GQLFEATURE_FLAG,
GQLPerspectiveExternalIntegration,
} from "coral-server/graph/schema/__generated__/types";
/**
* fetch is the phase hook fetcher used to communicate with the Perspective API.
*/
const fetch = createFetch({ name: "Hooks" });
export const toxic: IntermediateModerationPhase = async ({
tenant,
nudge,
@@ -64,16 +53,6 @@ export const toxic: IntermediateModerationPhase = async ({
return;
}
let endpoint = integration.endpoint;
if (isNil(endpoint)) {
endpoint = TOXICITY_ENDPOINT_DEFAULT;
log.trace(
{ endpoint },
"endpoint missing in integration settings, using defaults"
);
}
let threshold = integration.threshold;
if (isNil(threshold)) {
threshold = TOXICITY_THRESHOLD_DEFAULT / 100;
@@ -106,24 +85,24 @@ export const toxic: IntermediateModerationPhase = async ({
// Pull the custom model out.
const model = integration.model || TOXICITY_MODEL_DEFAULT;
// Get the language from the tenant's set language. This won't be a 1-1
// mapping because the Perspective API doesn't support all the languages
// that Coral supports in production.
const language = convertLanguage(tenant.locale);
// Call into the Toxic comment API.
const score = await getScore(
htmlStripped,
// Get the response from perspective.
const result = await sendToPerspective(
{ key: integration.key, endpoint: integration.endpoint, timeout },
{
endpoint,
key: integration.key,
doNotStore,
model,
},
language,
timeout
operation: "comments:analyze",
locale: tenant.locale,
body: {
text: htmlStripped,
doNotStore,
model,
},
}
);
// Reformat the scores.
const score = result.data.attributeScores[model].summaryScore
.value as number;
const isToxic = score > threshold;
if (isToxic) {
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
@@ -183,92 +162,3 @@ export const toxic: IntermediateModerationPhase = async ({
log.error({ err }, "could not determine comment toxicity");
}
};
/**
* Language is the language key that is supported by the Perspective API in the
* ISO 631-1 format.
*/
type PerspectiveLanguage = "en" | "es" | "fr" | "de";
/**
* convertLanguage returns the language code for the related Perspective API
* model in the ISO 631-1 format.
*
* @param locale the language on the tenant in the BCP 47 format.
*/
function convertLanguage(locale: LanguageCode): PerspectiveLanguage {
switch (locale) {
case "en-US":
return "en";
case "es":
return "es";
case "de":
return "de";
default:
return "en";
}
}
/**
* getScore will return the toxicity score for the comment text.
*
* @param text comment text to check for toxicity
* @param model the specific model to use when storing the toxicity
* @param language language to run perspective api
* @param timeout timeout for communicating with the perspective api
*/
async function getScore(
text: string,
{
key,
endpoint,
model,
doNotStore,
}: Required<
Omit<
GQLPerspectiveExternalIntegration,
"enabled" | "threshold" | "sendFeedback"
>
>,
language: PerspectiveLanguage,
timeout: number
): Promise<number> {
// Prepare the URL to send the command to.
const url = new URL(endpoint.trim());
url.pathname = path.join(url.pathname, "/comments:analyze");
url.searchParams.set("key", key.trim());
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
timeout,
body: JSON.stringify({
comment: {
text,
},
languages: [language],
doNotStore,
requestedAttributes: {
[model]: {},
},
}),
});
// Grab the data out of the Perspective API.
const data = await response.json();
// Reformat the scores.
return data.attributeScores[model].summaryScore.value as number;
} catch (err) {
// Ensure that the API key doesn't get leaked to the logs by accident.
if (err.message) {
err.message = err.message.replace(url.searchParams.toString(), "***");
}
// Rethrow the error.
throw err;
}
}
@@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput {
log: Logger;
story: Story;
tenant: Tenant;
comment: RequireProperty<Partial<CreateCommentInput>, "body">;
comment: RequireProperty<Partial<CreateCommentInput>, "body" | "ancestorIDs">;
author: User;
now: Date;
action: "NEW" | "EDIT";
@@ -24,6 +24,7 @@ export async function publishCommentStatusChanges(
oldStatus: GQLCOMMENT_STATUS,
newStatus: GQLCOMMENT_STATUS,
commentID: string,
commentRevisionID: string,
moderatorID: string | null
) {
if (oldStatus !== newStatus) {
@@ -31,6 +32,7 @@ export async function publishCommentStatusChanges(
newStatus,
oldStatus,
commentID,
commentRevisionID,
moderatorID,
});
}
+1 -162
View File
@@ -1,162 +1 @@
import { Db } from "mongodb";
import striptags from "striptags";
import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants";
import { reconstructTenantURL } from "coral-server/app/url";
import { Config } from "coral-server/config";
import logger from "coral-server/logger";
import { Comment } from "coral-server/models/comment";
import { getURLWithCommentID, retrieveStory } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import {
GQLCOMMENT_STATUS,
GQLPerspectiveExternalIntegration,
} from "coral-server/graph/schema/__generated__/types";
interface SendResult {
ok: boolean;
status: number;
data: any;
}
async function send(
endpoint: string,
apiKey: string,
method: string,
body: any
): Promise<SendResult> {
const result = await fetch(`${endpoint}/${method}?key=${apiKey}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body, null, 2),
});
if (!result.ok) {
return {
ok: result.ok,
status: result.status,
data: null,
};
}
const data = await result.json();
return {
ok: result.ok,
status: result.status,
data,
};
}
function computeStatus(status: GQLCOMMENT_STATUS) {
if (status === GQLCOMMENT_STATUS.APPROVED) {
return "APPROVED";
}
if (status === GQLCOMMENT_STATUS.REJECTED) {
return "DELETED";
}
return null;
}
export async function notifyPerspectiveModerationDecision(
mongo: Db,
tenant: Tenant,
config: Config,
perspectiveConfig: GQLPerspectiveExternalIntegration,
comment: Comment,
commentRevisionID: string,
status: GQLCOMMENT_STATUS
) {
if (
!perspectiveConfig.enabled ||
!perspectiveConfig.key ||
!perspectiveConfig.sendFeedback
) {
return;
}
const commentStatus = computeStatus(status);
if (!commentStatus) {
return;
}
const revision = comment.revisions.find(c => c.id === commentRevisionID);
if (!revision) {
logger.warn(
{ commentID: comment.id, commentRevisionID },
"unable to find comment revision ID in comment revision history"
);
return;
}
const endpoint = perspectiveConfig.endpoint
? perspectiveConfig.endpoint
: TOXICITY_ENDPOINT_DEFAULT;
const apiKey = perspectiveConfig.key;
const tenantUrl = reconstructTenantURL(config, tenant, undefined, "/");
const communityId = `Coral:${tenantUrl}`;
const clientToken = `comment:${comment.id}`;
try {
const story = await retrieveStory(mongo, comment.tenantID, comment.storyID);
if (!story) {
logger.warn({ storyID: comment.storyID }, "could not find story");
return;
}
const url = getURLWithCommentID(story.url, comment.id);
const body = {
comment: {
text: striptags(revision.body),
},
context: {
entries: [
{
text: JSON.stringify({
url,
reply_to_id_Coral_comment_id: comment.parentID,
Coral_comment_id: comment.id,
}),
},
],
},
attributeScores: {
[commentStatus]: {
summaryScore: {
value: 1,
},
},
},
languages: ["EN"],
communityId,
clientToken,
};
const result = await send(endpoint, apiKey, "comments:suggestscore", body);
if (result.ok) {
logger.debug(
{ commentID: comment.id, commentRevisionID },
"successfully sent moderation feedback to perspective"
);
} else if (!result.ok) {
logger.error(
{ status: result.status },
"unable to send moderation feedback to perspective"
);
} else if (!result.data || result.data.clientToken !== clientToken) {
logger.error(
{ data: result.data },
"result data from perspective did not contain the clientToken we expected"
);
}
} catch (err) {
logger.error({ err }, "unable to send moderation feedback to perspective");
}
}
export * from "./perspective";
@@ -0,0 +1,168 @@
import path from "path";
import { URL } from "url";
import { TOXICITY_ENDPOINT_DEFAULT } from "coral-common/constants";
import { LanguageCode } from "coral-common/helpers";
import { getURLWithCommentID } from "coral-server/models/story";
import { createFetch } from "coral-server/services/fetch";
const fetch = createFetch({ name: "perspective" });
/**
* Language is the language key that is supported by the Perspective API in the
* ISO 631-1 format.
*/
type PerspectiveLanguage = "en" | "es" | "fr" | "de";
/**
* convertLanguage returns the language code for the related Perspective API
* model in the ISO 631-1 format.
*
* @param locale the language on the tenant in the BCP 47 format.
*/
function convertLanguage(locale: LanguageCode): PerspectiveLanguage {
switch (locale) {
case "en-US":
return "en";
case "es":
return "es";
case "de":
return "de";
default:
return "en";
}
}
interface Options {
endpoint?: string;
key: string;
timeout: number;
}
type Request =
| {
operation: "comments:analyze";
locale: LanguageCode;
body: {
/**
* text is htmlStripped version of the comment text.
*/
text: string;
doNotStore: boolean;
model: string;
};
}
| {
operation: "comments:suggestscore";
locale: LanguageCode;
body: {
/**
* text is htmlStripped version of the comment text.
*/
text: string;
commentID: string;
commentParentID?: string;
commentStatus: "APPROVED" | "DELETED";
storyURL: string;
tenantURL: string;
};
};
function formatBody(req: Request): object {
// Get the language from the locale. This won't be a 1-1 mapping because the
// Perspective API doesn't support all the languages that Coral supports in
// production.
const language = convertLanguage(req.locale);
switch (req.operation) {
case "comments:analyze":
return {
comment: {
text: req.body.text,
},
doNotStore: req.body.doNotStore,
requestedAttributes: {
[req.body.model]: {},
},
languages: [language],
};
case "comments:suggestscore":
return {
comment: {
text: req.body.text,
},
context: {
entries: [
{
text: JSON.stringify({
url: getURLWithCommentID(req.body.storyURL, req.body.commentID),
reply_to_id_Coral_comment_id: req.body.commentParentID,
Coral_comment_id: req.body.commentID,
}),
},
],
},
attributeScores: {
[status]: {
summaryScore: {
value: 1,
},
},
},
languages: [language],
communityId: `Coral:${req.body.tenantURL}`,
clientToken: `comment:${req.body.commentID}`,
};
}
}
export async function sendToPerspective(
{ endpoint = TOXICITY_ENDPOINT_DEFAULT, key, timeout }: Options,
req: Request
) {
// Prepare the URL to send the command to.
const url = new URL(endpoint.trim());
url.pathname = path.join(url.pathname, `/${req.operation}`);
url.searchParams.set("key", key.trim());
// Convert the request to a body to be sent to perspective.
const body = JSON.stringify(formatBody(req));
try {
// Create the request and send it.
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
timeout,
body,
});
if (!res.ok) {
return {
ok: res.ok,
status: res.status,
data: null,
};
}
// Parse the JSON body and send back the result!
const data = await res.json();
return {
ok: res.ok,
status: res.status,
data,
};
} catch (err) {
// Ensure that the API key doesn't get leaked to the logs by accident.
if (err.message) {
err.message = err.message.replace(
url.searchParams.toString(),
"[Sensitive]"
);
}
// Rethrow the error.
throw err;
}
}
+1 -15
View File
@@ -1,10 +1,8 @@
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import { Tenant } from "coral-server/models/tenant";
import { moderate } from "coral-server/services/comments/moderation";
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
import { AugmentedRedis } from "coral-server/services/redis";
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
@@ -14,7 +12,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers";
const approveComment = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
broker: CoralEventPublisherBroker,
tenant: Tenant,
commentID: string,
@@ -48,19 +45,8 @@ const approveComment = async (
...result,
...counts,
moderatorID,
});
// We don't want to await on this so that
// we don't hold up the moderation flow and response
notifyPerspectiveModerationDecision(
mongo,
tenant,
config,
tenant.integrations.perspective,
result.after,
commentRevisionID,
GQLCOMMENT_STATUS.APPROVED
);
});
// Return the resulting comment.
return result.after;
+17 -33
View File
@@ -23,7 +23,6 @@ import {
retrieveComment,
} from "coral-server/models/comment";
import {
getLatestRevision,
hasAncestors,
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
@@ -43,10 +42,6 @@ import {
PhaseResult,
processForModeration,
} from "coral-server/services/comments/pipeline";
import {
publishCommentCreated,
publishCommentReplyCreated,
} from "coral-server/services/events";
import { AugmentedRedis } from "coral-server/services/redis";
import { updateUserLastCommentID } from "coral-server/services/users";
import { Request } from "coral-server/types/express";
@@ -67,7 +62,6 @@ export type CreateComment = Omit<
const markCommentAsAnswered = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
broker: CoralEventPublisherBroker,
tenant: Tenant,
comment: Readonly<Comment>,
@@ -93,16 +87,19 @@ const markCommentAsAnswered = async (
return;
}
// If we have experts and this reply is created by
// one of them, then this is an expert's answer.
if (story.settings.expertIDs.some(id => id === author.id)) {
// We need to mark this question as answered now.
// We can now remove the unanswered tag.
if (
// If we are the export on this story...
story.settings.expertIDs.some(id => id === author.id) &&
// And this is the first reply (depth of 1)...
comment.ancestorIDs.length === 1
) {
// We need to mark the parent question as answered.
// - Remove the unanswered tag.
// - Approve it since an expert has replied to it.
await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED);
await approveComment(
mongo,
redis,
config,
broker,
tenant,
comment.parentID,
@@ -181,7 +178,7 @@ export default async function create(
nudge,
story,
tenant,
comment: input,
comment: { ...input, ancestorIDs },
author,
req,
now,
@@ -218,7 +215,7 @@ export default async function create(
}
// Create the comment!
const comment = await createComment(
const { comment, revision } = await createComment(
mongo,
tenant.id,
{
@@ -234,6 +231,11 @@ export default async function create(
now
);
log = log.child(
{ commentID: comment.id, status, revisionID: revision.id },
true
);
// Updating some associated data.
await Promise.all([
updateUserLastCommentID(redis, tenant, author, comment.id),
@@ -241,7 +243,6 @@ export default async function create(
markCommentAsAnswered(
mongo,
redis,
config,
broker,
tenant,
comment,
@@ -251,14 +252,6 @@ export default async function create(
),
]);
// Pull the revision out.
const revision = getLatestRevision(comment);
log = log.child(
{ commentID: comment.id, status, revisionID: revision.id },
true
);
log.trace("comment created");
if (input.parentID) {
@@ -305,17 +298,8 @@ export default async function create(
await publishChanges(broker, {
...counts,
after: comment,
commentRevisionID: revision.id,
});
// If this is a reply, publish it.
if (input.parentID) {
publishCommentReplyCreated(broker, comment);
}
// If this comment is visible (and not a reply), publish it.
if (!input.parentID && hasPublishedStatus(comment)) {
publishCommentCreated(broker, comment);
}
return comment;
}
+1
View File
@@ -195,6 +195,7 @@ export default async function edit(
await publishChanges(broker, {
...result,
...counts,
commentRevisionID: result.revision.id,
});
// Return the resulting comment.
@@ -6,7 +6,9 @@ import {
hasPublishedStatus,
} from "coral-server/models/comment";
import {
publishCommentCreated,
publishCommentReleased,
publishCommentReplyCreated,
publishCommentStatusChanges,
publishModerationQueueChanges,
} from "coral-server/services/events";
@@ -16,6 +18,7 @@ interface PublishChangesInput {
after: Readonly<Comment>;
moderationQueue: CommentModerationQueueCounts;
moderatorID?: string;
commentRevisionID: string;
}
export default async function publishChanges(
@@ -33,11 +36,24 @@ export default async function publishChanges(
input.before.status,
input.after.status,
input.after.id,
input.commentRevisionID,
input.moderatorID || null
);
if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) {
publishCommentReleased(broker, input.after);
}
} else {
// This block is only hit if there is no before (if this is a new comment).
// If this is a reply, publish it.
if (input.after.parentID) {
publishCommentReplyCreated(broker, input.after);
}
// If this comment is visible (and not a reply), publish it.
if (!input.after.parentID && hasPublishedStatus(input.after)) {
publishCommentCreated(broker, input.after);
}
}
}
+1 -15
View File
@@ -1,12 +1,10 @@
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import { hasTag } from "coral-server/models/comment";
import { Tenant } from "coral-server/models/tenant";
import { removeTag } from "coral-server/services/comments";
import { moderate } from "coral-server/services/comments/moderation";
import { notifyPerspectiveModerationDecision } from "coral-server/services/perspective";
import { AugmentedRedis } from "coral-server/services/redis";
import {
@@ -19,7 +17,6 @@ import { publishChanges, updateAllCommentCounts } from "./helpers";
const rejectComment = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
broker: CoralEventPublisherBroker | null,
tenant: Tenant,
commentID: string,
@@ -56,6 +53,7 @@ const rejectComment = async (
...result,
...counts,
moderatorID,
commentRevisionID,
});
}
@@ -64,18 +62,6 @@ const rejectComment = async (
return removeTag(mongo, tenant, result.after.id, GQLTAG.FEATURED);
}
// We don't want to await on this so that
// we don't hold up the moderation flow and response
notifyPerspectiveModerationDecision(
mongo,
tenant,
config,
tenant.integrations.perspective,
result.after,
commentRevisionID,
GQLCOMMENT_STATUS.REJECTED
);
// Return the resulting comment.
return result.after;
};