mirror of
https://github.com/wassname/talk.git
synced 2026-09-11 12:51:33 +08:00
Q&A Beta (#2845)
* [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:
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -111,7 +111,11 @@ export default async function edit(
|
||||
config,
|
||||
story,
|
||||
tenant,
|
||||
comment: input,
|
||||
comment: {
|
||||
...originalStaleComment,
|
||||
...input,
|
||||
authorID: author.id,
|
||||
},
|
||||
author,
|
||||
req,
|
||||
now,
|
||||
|
||||
Reference in New Issue
Block a user