* [CORL-863] Allow streams to be converted/configured to Q&A (#2809)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* [CORL-856] Show expert badge on comments (#2829)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* [CORL-879] Add an unanswered tab to stream when in Q&A mode (#2838)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Do not visually show the unanswered tag

CORL-879

* [CORL-859] Convert Featured stream into Answered for Q&A (#2842)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Do not visually show the unanswered tag

CORL-879

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Use official copy for Q&A config

CORL-863

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Create the answered stream for Q&A

CORL-859

* Sort the Q&A on Most Voted by default

CORL-859

* Fix type mismatch between post comment form and fragments

CORL-859

* Adding localizations for Q&A tags

* Hide feature flags in Q&A streams

* Allow experts to clear answered questions

Can click a button that shows up in the unanswered
tab under answered questions to refresh the stream,
clearing the answered questions from that tab.

* Show arrow upvote icon when in Q&A mode

Also localized the upvote text so it can
be translated.

* Hide mod/report buttons on answered questions

* Remove unnecessary fragment container

* Remove errant debug console log

* Make story mode required on story settings

* Make remove button outlined, not filled

* Further schema changes around Q&A and experts

Rename add/remove story expert to removeStoryExpert
and addStoryExpert naming.

Replace enableQA and disableQA mutators with single
updateStoryMode mutator.

* Remove story mode from UpdateStorySettings

* Replace inline string val's with enum

* add dependencies to useEffect

* docs cleanup around tags.type index check

* Approve a question when it is answered in Q&A

Approves using the author's id as the moderator

* Add comment around use of TAG on comments edge

* Use tagFilter instead of $elemMatch to filter by tags

* Improve responsive styles for expert list items

* Update copy to "Done" from "Remove answered questions"

* Text styling for no users found text

Styles the expert search list to have proper
text styling when no users are found for the
search keyword.

* Remove duplicate checks around story experts

Not necessary as Mongo does this for us.

* Fix a missed "sort imports" during rebase

* Refactor Q&A moderation phases for clarity

Simplify logic and update comments.
Rename: "answered" -> "tagExpertAnswers"
Rename: "unanswered" -> "tagUnansweredQuestions"

* Remove username & email from add expert mutation

* Format expert list emails with Localized

* Break out no comments logic into fragment

* Remove ref handling from expert search field

Use value assignment on TextField instead.

* Replace Box with Flex and CSS

* Show Q&A tooltip on Answered tab

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Nick Funk
2020-02-25 20:10:25 -05:00
committed by GitHub
co-authored by Kim Gardner
parent 184f145c4a
commit 1a3401710f
100 changed files with 3761 additions and 114 deletions
+6 -1
View File
@@ -200,12 +200,17 @@ export default (ctx: Context) => ({
"tags.type": tag,
},
}).then(primeCommentsFromConnection(ctx)),
forStory: (storyID: string, { first, orderBy, after }: StoryToCommentsArgs) =>
forStory: (
storyID: string,
{ first, orderBy, after, tag }: StoryToCommentsArgs
) =>
retrieveCommentStoryConnection(ctx.mongo, ctx.tenant.id, storyID, {
first: defaultTo(first, 10),
orderBy: defaultTo(orderBy, GQLCOMMENT_SORT.CREATED_AT_DESC),
after,
filter: {
// Ensure we filter by the requested tag
...tagFilter(tag),
// Only get Comments that are top level. If the client wants to load
// another layer, they can request another nested connection.
parentID: null,
+12
View File
@@ -5,24 +5,30 @@ import GraphContext from "coral-server/graph/context";
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
import { Story } from "coral-server/models/story";
import {
addStoryExpert,
close,
create,
merge,
open,
remove,
removeStoryExpert,
update,
updateSettings,
updateStoryMode,
} from "coral-server/services/stories";
import { scrape } from "coral-server/services/stories/scraper";
import {
GQLAddExpertInput,
GQLCloseStoryInput,
GQLCreateStoryInput,
GQLMergeStoriesInput,
GQLOpenStoryInput,
GQLRemoveExpertInput,
GQLRemoveStoryInput,
GQLScrapeStoryInput,
GQLUpdateStoryInput,
GQLUpdateStoryModeInput,
GQLUpdateStorySettingsInput,
} from "coral-server/graph/schema/__generated__/types";
@@ -70,4 +76,10 @@ export const Stories = (ctx: GraphContext) => ({
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
addStoryExpert: async (input: GQLAddExpertInput) =>
addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
removeStoryExpert: async (input: GQLRemoveExpertInput) =>
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
});
@@ -256,6 +256,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
site: await ctx.mutators.Sites.update(input),
clientMutationId: input.clientMutationId,
}),
updateStoryMode: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.updateStoryMode(input),
clientMutationId: input.clientMutationId,
}),
addStoryExpert: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.addStoryExpert(input),
clientMutationId: input.clientMutationId,
}),
removeStoryExpert: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.removeStoryExpert(input),
clientMutationId: input.clientMutationId,
}),
createWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
@@ -1,6 +1,9 @@
import * as story from "coral-server/models/story";
import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
import {
GQLSTORY_MODE,
GQLStorySettingsTypeResolver,
} from "../schema/__generated__/types";
export const StorySettings: GQLStorySettingsTypeResolver<
story.StorySettings
@@ -18,4 +21,18 @@ export const StorySettings: GQLStorySettingsTypeResolver<
enabled: false,
};
},
mode: s => {
if (s.mode) {
return s.mode;
}
return GQLSTORY_MODE.COMMENTS;
},
experts: (s, input, ctx) => {
if (s.expertIDs) {
return ctx.loaders.Users.user.loadMany(s.expertIDs);
}
return [];
},
};
+167
View File
@@ -372,6 +372,18 @@ type WordList {
suspect: [String!]!
}
enum STORY_MODE {
"""
Coments is when a story is used for general commenting.
"""
COMMENTS
"""
QA is used for when the story is in Q&A mode.
"""
QA
}
################################################################################
## Moderation
################################################################################
@@ -2345,6 +2357,18 @@ enum TAG {
FEATURED is used when a Comment is marked as such by a staff member.
"""
FEATURED
"""
EXPERT is used when an a Comment is written by a User that is assigned as
an expert on a story.
"""
EXPERT
"""
UNANSWERED is used when a Comment is written by a User and is unanswered
by an expert for that Comment's story.
"""
UNANSWERED
}
"""
@@ -2376,6 +2400,11 @@ type CommentTagCounts {
FEATURED is the count of Comment's with the FEATURED tag.
"""
FEATURED: Int!
"""
UNANSWERED is the count of Comment's with the UNANSWERED tag.
"""
UNANSWERED: Int!
}
"""
@@ -2720,6 +2749,19 @@ type StorySettings {
messageBox stores settings related to the Story Message Box.
"""
messageBox: StoryMessageBox!
"""
mode is whether the story stream is in commenting or Q&A mode.
This will determine the appearance of the stream and how it functions.
"""
mode: STORY_MODE!
"""
experts are used during Q&A mode to assign users to answer questions
on a Q&A stream. It is an optional parameter and is only used when
the story stream is in Q&A mode.
"""
experts: [User!]!
}
"""
@@ -2783,6 +2825,14 @@ type Story {
first: Int = 20 @constraint(max: 50)
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
"""
This is a workaround to allow filtering for current Q&A
functionality. This is used to filter on UNANSWERED to
populate its corresponding Unanswered questions tab. In
the future, we want a dedicated edge for unansweredComments.
"""
tag: TAG
): CommentsConnection!
"""
@@ -5827,6 +5877,97 @@ type DisableFeatureFlagPayload {
flags: [FEATURE_FLAG!]!
}
#########################
# Add / Remove Expert
#########################
input AddExpertInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
storyID is the story to add the expert to.
"""
storyID: ID!
"""
userID is the user to add as an expert to the story.
"""
userID: ID!
}
type AddExpertPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
story is the resultant story the expert was added to.
"""
story: Story!
}
input RemoveExpertInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
storyID is the story to remove the expert from.
"""
storyID: ID!
"""
userID is the user to remove as an expert from the story.
"""
userID: ID!
}
type RemoveExpertPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
story is the resultant story the expert was removed from.
"""
story: Story!
}
input UpdateStoryModeInput {
"""
storyID is the story id to enable Q&A on.
"""
storyID: ID!
"""
mode is the mode to set the story to.
"""
mode: STORY_MODE!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateStoryModePayload {
"""
story is the resultant story that Q&A was enabled on.
"""
story: Story!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -6199,10 +6340,16 @@ type Mutation {
input: DisableFeatureFlagInput!
): DisableFeatureFlagPayload! @auth(roles: [ADMIN])
"""
createAnnouncement creates a global announcement.
"""
createAnnouncement(
input: CreateAnnouncementInput!
): CreateAnnouncementPayload! @auth(roles: [ADMIN])
"""
deleteAnnouncement removes a global announcement.
"""
deleteAnnouncement(
input: DeleteAnnouncementInput!
): DeleteAnnouncementPayload! @auth(roles: [ADMIN])
@@ -6253,6 +6400,26 @@ type Mutation {
rotateWebhookEndpointSecret(
input: RotateWebhookEndpointSecretInput!
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
"""
updateStoryMode will set the story mode.
"""
updateStoryMode(input: UpdateStoryModeInput!): UpdateStoryModePayload!
@auth(roles: [ADMIN, MODERATOR])
"""
addStoryExpert adds an expert to a story.
"""
addStoryExpert(
input: AddExpertInput!
): AddExpertPayload! @auth(roles: [ADMIN, MODERATOR])
"""
removeStoryExpert removes an expert from a story.
"""
removeStoryExpert(
input: RemoveExpertInput!
): RemoveExpertPayload! @auth(roles: [ADMIN, MODERATOR])
}
##################
+4 -6
View File
@@ -926,12 +926,9 @@ export async function retrieveStoryCommentTagCounts(
// Build up the $match query.
const $match: FilterQuery<Comment> = {
tenantID,
// We're filtering only for featured comments for now because that's all
// that is returned by the tag counts at the moment. If we ever extend this
// we should switch this out to something like
// `"tags.type": { $exists: true }` to ensure that we are using the
// specified index.
"tags.type": GQLTAG.FEATURED,
// ensure we are using the tags.type index, this
// currently includes FEATURED and UNANSWERED tags
"tags.type": { $exists: true },
// Only show published comment's tag counts.
status: { $in: PUBLISHED_STATUSES },
};
@@ -986,6 +983,7 @@ export async function retrieveStoryCommentTagCounts(
// missing/extra tags here.
{
[GQLTAG.FEATURED]: 0,
[GQLTAG.UNANSWERED]: 0,
}
);
});
+120 -1
View File
@@ -6,6 +6,7 @@ import { dotize } from "coral-common/utils/dotize";
import {
DuplicateStoryIDError,
DuplicateStoryURLError,
StoryNotFoundError,
} from "coral-server/errors";
import {
Connection,
@@ -18,6 +19,7 @@ import { TenantResource } from "coral-server/models/tenant";
import { stories as collection } from "coral-server/services/mongodb/collections";
import {
GQLSTORY_MODE,
GQLStoryMetadata,
GQLStorySettings,
} from "coral-server/graph/schema/__generated__/types";
@@ -30,8 +32,26 @@ import {
export * from "./helpers";
export interface StreamModeSettings {
/**
* mode is whether the story stream is in commenting or Q&A mode.
* This will determine the appearance of the stream and how it functions.
* This is an optional parameter and if unset, defaults to commenting.
*/
mode?: GQLSTORY_MODE;
/**
* experts are used during Q&A mode to assign users to answer questions
* on a Q&A stream. It is an optional parameter and is only used when
* the story stream is in Q&A mode.
*/
expertIDs?: string[];
}
export type StorySettings = DeepPartial<
Pick<GQLStorySettings, "messageBox"> & GlobalModerationSettings
StreamModeSettings &
GlobalModerationSettings &
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">
>;
export type StoryMetadata = GQLStoryMetadata;
@@ -544,3 +564,102 @@ export const updateStoryCounts = (
id: string,
commentCounts: FirstDeepPartial<RelatedCommentCounts>
) => updateRelatedCommentCounts(collection(mongo), tenantID, id, commentCounts);
export async function addExpert(
mongo: Db,
tenantID: string,
storyID: string,
userID: string
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$addToSet: {
"settings.expertIDs": userID,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to add expert to story");
}
return result.value || null;
}
export async function removeExpert(
mongo: Db,
tenantID: string,
storyID: string,
userID: string
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$pull: {
"settings.expertIDs": userID,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to remove expert from story");
}
return result.value || null;
}
export async function setStoryMode(
mongo: Db,
tenantID: string,
storyID: string,
mode: GQLSTORY_MODE
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$set: {
"settings.mode": mode,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to enable Q&A on story");
}
return result.value || null;
}
@@ -0,0 +1,41 @@
import { CommentTag } from "coral-server/models/comment/tag";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLCOMMENT_STATUS,
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.
//
// STAFF: all staff comments are automatically approved.
//
// EXPERT: when in Q&A mode, all expert comments are
// automatically approved. We will only see EXPERT
// tags assigned when we are in Q&A mode, so we can
// trust this simple tag type check.
if (
tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
) {
result.status = GQLCOMMENT_STATUS.APPROVED;
}
return result;
};
@@ -1,5 +1,6 @@
import { IntermediateModerationPhase } from "coral-server/services/comments/pipeline";
import { approve } from "./approve";
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
import { detectLinks } from "./detectLinks";
@@ -13,6 +14,8 @@ import { repeatPost } from "./repeatPost";
import { spam } from "./spam";
import { staff } from "./staff";
import { storyClosed } from "./storyClosed";
import { tagExpertAnswers } from "./tagExpertAnswers";
import { tagUnansweredQuestions } from "./tagUnansweredQuestions";
import { toxic } from "./toxic";
import { wordList } from "./wordList";
@@ -27,7 +30,10 @@ export const moderationPhases: IntermediateModerationPhase[] = [
purify,
repeatPost,
wordList,
tagExpertAnswers,
staff,
tagUnansweredQuestions,
approve,
toxic,
recentCommentHistory,
spam,
@@ -1,5 +1,4 @@
import {
GQLCOMMENT_STATUS,
GQLTAG,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
@@ -15,7 +14,6 @@ export const staff: IntermediateModerationPhase = ({
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
return {
status: GQLCOMMENT_STATUS.APPROVED,
tags: [
{
type: GQLTAG.STAFF,
@@ -0,0 +1,34 @@
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
export const tagExpertAnswers: IntermediateModerationPhase = ({
author,
now,
story,
}): IntermediatePhaseResult | void => {
if (
story.settings.mode === GQLSTORY_MODE.QA &&
story.settings.expertIDs &&
story.settings.expertIDs.some(id => id === author.id)
) {
return {
tags: [
{
type: GQLTAG.EXPERT,
createdAt: now,
},
{
type: GQLTAG.FEATURED,
createdAt: now,
},
],
};
}
};
@@ -0,0 +1,42 @@
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
export const tagUnansweredQuestions: IntermediateModerationPhase = ({
comment,
story,
now,
}): IntermediatePhaseResult | void => {
// We only show unanswered tags in Q&A.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
return;
}
// Only top level comments are questions,
// everything else is replies for Q&A.
if (comment.parentID) {
return;
}
// If we have no experts, or the current author is
// not an expert, then this is an UNANSWERED comment.
if (
!story.settings.expertIDs ||
story.settings.expertIDs.every(id => id !== comment.authorID)
) {
return {
tags: [
{
type: GQLTAG.UNANSWERED,
createdAt: now,
},
],
};
}
};
@@ -6,7 +6,7 @@ import { Config } from "coral-server/config";
import { Logger } from "coral-server/logger";
import { CreateActionInput } from "coral-server/models/action/comment";
import {
EditCommentInput,
CreateCommentInput,
RevisionMetadata,
} from "coral-server/models/comment";
import { CommentTag } from "coral-server/models/comment/tag";
@@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput {
log: Logger;
story: Story;
tenant: Tenant;
comment: RequireProperty<Partial<EditCommentInput>, "body">;
comment: RequireProperty<Partial<CreateCommentInput>, "body">;
author: User;
now: Date;
action: "NEW" | "EDIT";
@@ -64,6 +64,7 @@ export type IntermediatePhaseResult = Partial<PhaseResult> | void;
export interface IntermediateModerationPhaseContext
extends ModerationPhaseContext {
metadata: RevisionMetadata;
tags: CommentTag[];
}
export type IntermediateModerationPhase = (
@@ -103,6 +104,7 @@ export const compose = (
...context.comment,
body: final.body,
},
tags: final.tags,
htmlStripped,
metadata: final.metadata,
});
+44 -1
View File
@@ -3,7 +3,7 @@ import { Db } from "mongodb";
import isNonNullArray from "coral-common/helpers/isNonNullArray";
import { Config } from "coral-server/config";
import { StoryURLInvalidError } from "coral-server/errors";
import { StoryURLInvalidError, UserNotFoundError } from "coral-server/errors";
import { StoryCreatedCoralEvent } from "coral-server/events";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import logger from "coral-server/logger";
@@ -20,6 +20,7 @@ import {
removeStoryComments,
} from "coral-server/models/comment";
import {
addExpert,
closeStory,
createStory,
CreateStoryInput,
@@ -28,10 +29,12 @@ import {
findStory,
FindStoryInput,
openStory,
removeExpert,
removeStories,
removeStory,
retrieveManyStories,
retrieveStory,
setStoryMode,
Story,
updateStory,
updateStoryCounts,
@@ -40,10 +43,13 @@ import {
UpdateStorySettingsInput,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { findSiteByURL } from "coral-server/services/sites";
import { scrape } from "coral-server/services/stories/scraper";
import { GQLSTORY_MODE } from "coral-server/graph/schema/__generated__/types";
export type FindStory = FindStoryInput;
export async function find(mongo: Db, tenant: Tenant, input: FindStory) {
@@ -367,3 +373,40 @@ export async function merge(
// Return the story that had the other stories merged into.
return destinationStory;
}
export async function addStoryExpert(
mongo: Db,
tenant: Tenant,
storyID: string,
userID: string
) {
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
return addExpert(mongo, tenant.id, storyID, userID);
}
export async function removeStoryExpert(
mongo: Db,
tenant: Tenant,
storyID: string,
userID: string
) {
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
return removeExpert(mongo, tenant.id, storyID, userID);
}
export async function updateStoryMode(
mongo: Db,
tenant: Tenant,
storyID: string,
mode: GQLSTORY_MODE
) {
return setStoryMode(mongo, tenant.id, storyID, mode);
}
+69
View File
@@ -16,6 +16,7 @@ import {
filterDuplicateActions,
} from "coral-server/models/action/comment";
import {
Comment,
createComment,
CreateCommentInput,
pushChildCommentIDOntoParent,
@@ -28,10 +29,12 @@ import {
} from "coral-server/models/comment/helpers";
import {
retrieveStory,
Story,
updateStoryLastCommentedAt,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { removeTag } from "coral-server/services/comments";
import {
addCommentActions,
CreateAction,
@@ -48,6 +51,12 @@ import { AugmentedRedis } from "coral-server/services/redis";
import { updateUserLastCommentID } from "coral-server/services/users";
import { Request } from "coral-server/types/express";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
import approveComment from "./approveComment";
import { publishChanges, updateAllCommentCounts } from "./helpers";
export type CreateComment = Omit<
@@ -55,6 +64,55 @@ export type CreateComment = Omit<
"status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" | "siteID"
>;
const markCommentAsAnswered = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
broker: CoralEventPublisherBroker,
tenant: Tenant,
comment: Readonly<Comment>,
story: Story,
author: User,
now: Date
) => {
// We only process this if we're in Q&A mode.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
return;
}
// Answers are always a reply to another comment.
// If we have a parentID and a parentRevisionID, then
// we have a parent, which means we are replying.
if (!comment.parentID || !comment.parentRevisionID) {
return;
}
// If we have no experts, there cannot be anyone
// providing expert answers.
if (!story.settings.expertIDs) {
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.
await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED);
await approveComment(
mongo,
redis,
config,
broker,
tenant,
comment.parentID,
comment.parentRevisionID,
author.id,
now
);
}
};
export default async function create(
mongo: Db,
redis: AugmentedRedis,
@@ -180,6 +238,17 @@ export default async function create(
await Promise.all([
updateUserLastCommentID(redis, tenant, author, comment.id),
updateStoryLastCommentedAt(mongo, tenant.id, story.id, now),
markCommentAsAnswered(
mongo,
redis,
config,
broker,
tenant,
comment,
story,
author,
now
),
]);
// Pull the revision out.
+5 -1
View File
@@ -111,7 +111,11 @@ export default async function edit(
config,
story,
tenant,
comment: input,
comment: {
...originalStaleComment,
...input,
authorID: author.id,
},
author,
req,
now,