[CORL-1156] Q&A Feature Flag Fix (#3000)

* fix: fixed bug with feature flag handling

* chore: version bump
This commit is contained in:
Wyatt Johnson
2020-06-25 18:59:03 +00:00
committed by GitHub
parent 9022532525
commit bf04b4c2a6
9 changed files with 719 additions and 709 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "6.2.2",
"version": "6.2.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "6.2.2",
"version": "6.2.3",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -1,11 +1,6 @@
import * as story from "coral-server/models/story";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
GQLFEATURE_FLAG,
GQLSTORY_MODE,
GQLStorySettingsTypeResolver,
} from "../schema/__generated__/types";
import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
import { LiveConfigurationInput } from "./LiveConfiguration";
@@ -35,18 +30,7 @@ export const StorySettings: GQLStorySettingsTypeResolver<StorySettingsInput> = {
};
},
// FEATURE_FLAG:ENABLE_QA
mode: (s, input, ctx) => {
if (s.mode) {
return s.mode;
}
// FEATURE_FLAG:DEFAULT_QA_STORY_MODE
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.DEFAULT_QA_STORY_MODE)) {
return GQLSTORY_MODE.QA;
}
return GQLSTORY_MODE.COMMENTS;
},
mode: (s, input, ctx) => story.resolveStoryMode(s, ctx.tenant),
experts: (s, input, ctx) => {
if (s.expertIDs) {
return ctx.loaders.Users.user.loadMany(s.expertIDs);
+23 -2
View File
@@ -2,9 +2,14 @@ import { DateTime } from "luxon";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
import { Tenant } from "coral-server/models/tenant";
import { hasFeatureFlag, Tenant } from "coral-server/models/tenant";
import { Story } from ".";
import {
GQLFEATURE_FLAG,
GQLSTORY_MODE,
} from "coral-server/graph/schema/__generated__/types";
import { Story } from "./story";
/**
* getURLWithCommentID returns the url with the comment id.
@@ -61,3 +66,19 @@ export function getStoryClosedAt(
return null;
}
export function resolveStoryMode(
storySettings: Story["settings"],
tenant: Pick<Tenant, "featureFlags">
) {
if (storySettings.mode) {
return storySettings.mode;
}
// FEATURE_FLAG:DEFAULT_QA_STORY_MODE
if (hasFeatureFlag(tenant, GQLFEATURE_FLAG.DEFAULT_QA_STORY_MODE)) {
return GQLSTORY_MODE.QA;
}
return GQLSTORY_MODE.COMMENTS;
}
+1 -682
View File
@@ -1,683 +1,2 @@
import { Db, MongoError } from "mongodb";
import { v4 as uuid } from "uuid";
import { DeepPartial, FirstDeepPartial } from "coral-common/types";
import { dotize } from "coral-common/utils/dotize";
import {
DuplicateStoryIDError,
DuplicateStoryURLError,
StoryNotFoundError,
} from "coral-server/errors";
import {
Connection,
ConnectionInput,
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { GlobalModerationSettings } from "coral-server/models/settings";
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";
import {
createEmptyRelatedCommentCounts,
RelatedCommentCounts,
updateRelatedCommentCounts,
} from "../comment/counts";
export * from "./story";
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 = StreamModeSettings &
GlobalModerationSettings &
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">;
export type StoryMetadata = GQLStoryMetadata;
export interface Story extends TenantResource {
readonly id: string;
/**
* url is the URL to the Story page.
*/
url: string;
/**
* metadata stores the scraped metadata from the Story page.
*/
metadata?: StoryMetadata;
/**
* scrapedAt is the Time that the Story had it's metadata scraped at.
*/
scrapedAt?: Date;
/**
* commentCounts stores all the comment counters.
*/
commentCounts: RelatedCommentCounts;
/**
* settings provides a point where the settings can be overridden for a
* specific Story.
*/
settings: DeepPartial<StorySettings>;
/**
* closedAt is the date that the Story was forced closed at, or false to
* indicate that the story was re-opened.
*/
closedAt?: Date | false;
/**
* createdAt is the date that the Story was added to the Coral database.
*/
createdAt: Date;
/**
* lastCommentedAt is the last time someone commented on this story.
*/
lastCommentedAt?: Date;
/**
* siteID references the site the story belongs to
*/
siteID: string;
}
export interface UpsertStoryInput {
id?: string;
url: string;
siteID: string;
}
export interface UpsertStoryResult {
story: Story;
wasUpserted: boolean;
}
export async function upsertStory(
mongo: Db,
tenantID: string,
{ id = uuid(), url, siteID }: UpsertStoryInput,
now = new Date()
): Promise<UpsertStoryResult> {
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenantID.
const story: Story = {
id,
url,
tenantID,
siteID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
// Perform the find and update operation to try and find and or create the
// story.
const result = await collection(mongo).findOneAndUpdate(
{
url,
tenantID,
},
{ $setOnInsert: story },
{
// Create the object if it doesn't already exist.
upsert: true,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new Story, it
// should return null.
returnOriginal: true,
}
);
return {
// The story will either be found (via `result.value`) or upserted (via
// `story`).
story: result.value || story,
// The story was upserted if the value isn't provided.
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryIDError(err, id, url);
}
throw err;
}
}
export interface FindStoryInput {
id?: string;
url?: string;
}
export async function findStory(
mongo: Db,
tenantID: string,
{ id, url }: FindStoryInput
) {
if (id) {
return retrieveStory(mongo, tenantID, id);
}
if (url) {
return retrieveStoryByURL(mongo, tenantID, url);
}
// Story can't be found with that ID/URL combination and scraping is
// disabled, so we fail here.
return null;
}
export interface FindOrCreateStoryInput {
id?: string;
url?: string;
}
export interface FindOrCreateStoryResult {
story: Story | null;
wasUpserted: boolean;
}
export async function findOrCreateStory(
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput,
siteID: string | null,
now = new Date()
): Promise<FindOrCreateStoryResult> {
if (id) {
if (url && siteID) {
// The URL was specified, this is an upsert operation.
return upsertStory(
mongo,
tenantID,
{
id,
url,
siteID,
},
now
);
}
// The URL was not specified, this is a lookup operation.
const story = await retrieveStory(mongo, tenantID, id);
// Return the result object.
return {
story,
wasUpserted: false,
};
}
// The ID was not specified, this is an upsert operation. Check to see that
// the URL exists.
if (!url) {
throw new Error("cannot upsert an story without the url");
}
if (!siteID) {
throw new Error("cannot upsert story without site ID");
}
return upsertStory(mongo, tenantID, { url, siteID }, now);
}
export type CreateStoryInput = Partial<
Pick<Story, "metadata" | "scrapedAt" | "closedAt">
> & {
siteID: string;
};
export async function createStory(
mongo: Db,
tenantID: string,
id: string,
url: string,
input: CreateStoryInput,
now = new Date()
) {
// Create the story.
const story: Story = {
...input,
id,
url,
tenantID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
// Insert the story into the database.
await collection(mongo).insertOne(story);
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryURLError(err, url, id);
}
throw err;
}
// Return the created story.
return story;
}
export async function retrieveStoryByURL(
mongo: Db,
tenantID: string,
url: string
) {
return collection(mongo).findOne({ url, tenantID });
}
export async function retrieveStory(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ id, tenantID });
}
export async function retrieveManyStories(
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = collection(mongo).find({
id: { $in: ids },
tenantID,
});
const stories = await cursor.toArray();
return ids.map((id) => stories.find((story) => story.id === id) || null);
}
export async function retrieveManyStoriesByURL(
mongo: Db,
tenantID: string,
urls: string[]
) {
const cursor = collection(mongo).find({
url: { $in: urls },
tenantID,
});
const stories = await cursor.toArray();
return urls.map((url) => stories.find((story) => story.url === url) || null);
}
export type UpdateStoryInput = Omit<
Partial<Story>,
"id" | "tenantID" | "closedAt" | "createdAt" | "siteID"
>;
export async function updateStory(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStoryInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize(input, { embedArrays: true }),
// Always update the updated at time.
updatedAt: now,
},
};
try {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (input.url && err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryURLError(err, input.url, id);
}
throw err;
}
}
export type UpdateStorySettingsInput = DeepPartial<StorySettings>;
export async function updateStorySettings(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStorySettingsInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize({ settings: input }, { embedArrays: true }),
// Always update the updated at time.
updatedAt: now,
},
};
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function openStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: false,
// Always update the updated at time.
updatedAt: now,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function closeStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: now,
// Always update the updated at time.
updatedAt: now,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function removeStory(mongo: Db, tenantID: string, id: string) {
const result = await collection(mongo).findOneAndDelete({
id,
tenantID,
});
return result.value || null;
}
/**
* removeStories will remove the stories specified by the set of id's.
*/
export async function removeStories(
mongo: Db,
tenantID: string,
ids: string[]
) {
return collection(mongo).deleteMany({
tenantID,
id: {
$in: ids,
},
});
}
export type StoryConnectionInput = ConnectionInput<Story>;
export async function retrieveStoryConnection(
mongo: Db,
tenantID: string,
input: StoryConnectionInput
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: StoryConnectionInput,
query: Query<Story>
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Apply the pagination arguments to the query.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// Return a connection.
return resolveConnection(query, input, (story) => story.createdAt);
}
export async function retrieveActiveStories(
mongo: Db,
tenantID: string,
limit: number
) {
const stories = await collection(mongo)
.find({
tenantID,
// We limit this query to stories that have the following field. This
// allows us to use the index.
lastCommentedAt: {
$exists: true,
},
})
.sort({ lastCommentedAt: -1 })
.limit(limit)
.toArray();
return stories;
}
export async function updateStoryLastCommentedAt(
mongo: Db,
tenantID: string,
storyID: string,
now: Date
) {
await collection(mongo).updateOne(
{
tenantID,
id: storyID,
},
{
$set: {
lastCommentedAt: now,
},
}
);
}
/**
* updateStoryCounts will update the comment counts for the story indicated.
*
* @param mongo mongodb database handle
* @param tenantID ID of the Tenant where the Story is on
* @param id the ID of the Story that we are updating counts on
* @param commentCounts the counts that we are updating
*/
export const updateStoryCounts = (
mongo: Db,
tenantID: string,
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;
}
/**
* retrieveStorySections will return the sections used by stories in the
* database for a given Tenant sorted alphabetically.
*
* @param mongo the database connection to use to retrieve the data
* @param tenantID the ID of the Tenant that we're retrieving data
*/
export async function retrieveStorySections(
mongo: Db,
tenantID: string
): Promise<string[]> {
const results: Array<string | null> = await collection(
mongo
).distinct("metadata.section", { tenantID });
// We perform the type assertion here because we know that after filtering out
// the null entries, the resulting array can not contain null.
return results.filter((section) => section !== null).sort() as string[];
}
+683
View File
@@ -0,0 +1,683 @@
import { Db, MongoError } from "mongodb";
import { v4 as uuid } from "uuid";
import { DeepPartial, FirstDeepPartial } from "coral-common/types";
import { dotize } from "coral-common/utils/dotize";
import {
DuplicateStoryIDError,
DuplicateStoryURLError,
StoryNotFoundError,
} from "coral-server/errors";
import {
Connection,
ConnectionInput,
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { GlobalModerationSettings } from "coral-server/models/settings";
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";
import {
createEmptyRelatedCommentCounts,
RelatedCommentCounts,
updateRelatedCommentCounts,
} from "../comment/counts";
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 = StreamModeSettings &
GlobalModerationSettings &
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">;
export type StoryMetadata = GQLStoryMetadata;
export interface Story extends TenantResource {
readonly id: string;
/**
* url is the URL to the Story page.
*/
url: string;
/**
* metadata stores the scraped metadata from the Story page.
*/
metadata?: StoryMetadata;
/**
* scrapedAt is the Time that the Story had it's metadata scraped at.
*/
scrapedAt?: Date;
/**
* commentCounts stores all the comment counters.
*/
commentCounts: RelatedCommentCounts;
/**
* settings provides a point where the settings can be overridden for a
* specific Story.
*/
settings: DeepPartial<StorySettings>;
/**
* closedAt is the date that the Story was forced closed at, or false to
* indicate that the story was re-opened.
*/
closedAt?: Date | false;
/**
* createdAt is the date that the Story was added to the Coral database.
*/
createdAt: Date;
/**
* lastCommentedAt is the last time someone commented on this story.
*/
lastCommentedAt?: Date;
/**
* siteID references the site the story belongs to
*/
siteID: string;
}
export interface UpsertStoryInput {
id?: string;
url: string;
siteID: string;
}
export interface UpsertStoryResult {
story: Story;
wasUpserted: boolean;
}
export async function upsertStory(
mongo: Db,
tenantID: string,
{ id = uuid(), url, siteID }: UpsertStoryInput,
now = new Date()
): Promise<UpsertStoryResult> {
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenantID.
const story: Story = {
id,
url,
tenantID,
siteID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
// Perform the find and update operation to try and find and or create the
// story.
const result = await collection(mongo).findOneAndUpdate(
{
url,
tenantID,
},
{ $setOnInsert: story },
{
// Create the object if it doesn't already exist.
upsert: true,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new Story, it
// should return null.
returnOriginal: true,
}
);
return {
// The story will either be found (via `result.value`) or upserted (via
// `story`).
story: result.value || story,
// The story was upserted if the value isn't provided.
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryIDError(err, id, url);
}
throw err;
}
}
export interface FindStoryInput {
id?: string;
url?: string;
}
export async function findStory(
mongo: Db,
tenantID: string,
{ id, url }: FindStoryInput
) {
if (id) {
return retrieveStory(mongo, tenantID, id);
}
if (url) {
return retrieveStoryByURL(mongo, tenantID, url);
}
// Story can't be found with that ID/URL combination and scraping is
// disabled, so we fail here.
return null;
}
export interface FindOrCreateStoryInput {
id?: string;
url?: string;
}
export interface FindOrCreateStoryResult {
story: Story | null;
wasUpserted: boolean;
}
export async function findOrCreateStory(
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput,
siteID: string | null,
now = new Date()
): Promise<FindOrCreateStoryResult> {
if (id) {
if (url && siteID) {
// The URL was specified, this is an upsert operation.
return upsertStory(
mongo,
tenantID,
{
id,
url,
siteID,
},
now
);
}
// The URL was not specified, this is a lookup operation.
const story = await retrieveStory(mongo, tenantID, id);
// Return the result object.
return {
story,
wasUpserted: false,
};
}
// The ID was not specified, this is an upsert operation. Check to see that
// the URL exists.
if (!url) {
throw new Error("cannot upsert an story without the url");
}
if (!siteID) {
throw new Error("cannot upsert story without site ID");
}
return upsertStory(mongo, tenantID, { url, siteID }, now);
}
export type CreateStoryInput = Partial<
Pick<Story, "metadata" | "scrapedAt" | "closedAt">
> & {
siteID: string;
};
export async function createStory(
mongo: Db,
tenantID: string,
id: string,
url: string,
input: CreateStoryInput,
now = new Date()
) {
// Create the story.
const story: Story = {
...input,
id,
url,
tenantID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
// Insert the story into the database.
await collection(mongo).insertOne(story);
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryURLError(err, url, id);
}
throw err;
}
// Return the created story.
return story;
}
export async function retrieveStoryByURL(
mongo: Db,
tenantID: string,
url: string
) {
return collection(mongo).findOne({ url, tenantID });
}
export async function retrieveStory(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ id, tenantID });
}
export async function retrieveManyStories(
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = collection(mongo).find({
id: { $in: ids },
tenantID,
});
const stories = await cursor.toArray();
return ids.map((id) => stories.find((story) => story.id === id) || null);
}
export async function retrieveManyStoriesByURL(
mongo: Db,
tenantID: string,
urls: string[]
) {
const cursor = collection(mongo).find({
url: { $in: urls },
tenantID,
});
const stories = await cursor.toArray();
return urls.map((url) => stories.find((story) => story.url === url) || null);
}
export type UpdateStoryInput = Omit<
Partial<Story>,
"id" | "tenantID" | "closedAt" | "createdAt" | "siteID"
>;
export async function updateStory(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStoryInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize(input, { embedArrays: true }),
// Always update the updated at time.
updatedAt: now,
},
};
try {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (input.url && err instanceof MongoError && err.code === 11000) {
throw new DuplicateStoryURLError(err, input.url, id);
}
throw err;
}
}
export type UpdateStorySettingsInput = DeepPartial<StorySettings>;
export async function updateStorySettings(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStorySettingsInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize({ settings: input }, { embedArrays: true }),
// Always update the updated at time.
updatedAt: now,
},
};
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function openStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: false,
// Always update the updated at time.
updatedAt: now,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function closeStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: now,
// Always update the updated at time.
updatedAt: now,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function removeStory(mongo: Db, tenantID: string, id: string) {
const result = await collection(mongo).findOneAndDelete({
id,
tenantID,
});
return result.value || null;
}
/**
* removeStories will remove the stories specified by the set of id's.
*/
export async function removeStories(
mongo: Db,
tenantID: string,
ids: string[]
) {
return collection(mongo).deleteMany({
tenantID,
id: {
$in: ids,
},
});
}
export type StoryConnectionInput = ConnectionInput<Story>;
export async function retrieveStoryConnection(
mongo: Db,
tenantID: string,
input: StoryConnectionInput
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: StoryConnectionInput,
query: Query<Story>
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Apply the pagination arguments to the query.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// Return a connection.
return resolveConnection(query, input, (story) => story.createdAt);
}
export async function retrieveActiveStories(
mongo: Db,
tenantID: string,
limit: number
) {
const stories = await collection(mongo)
.find({
tenantID,
// We limit this query to stories that have the following field. This
// allows us to use the index.
lastCommentedAt: {
$exists: true,
},
})
.sort({ lastCommentedAt: -1 })
.limit(limit)
.toArray();
return stories;
}
export async function updateStoryLastCommentedAt(
mongo: Db,
tenantID: string,
storyID: string,
now: Date
) {
await collection(mongo).updateOne(
{
tenantID,
id: storyID,
},
{
$set: {
lastCommentedAt: now,
},
}
);
}
/**
* updateStoryCounts will update the comment counts for the story indicated.
*
* @param mongo mongodb database handle
* @param tenantID ID of the Tenant where the Story is on
* @param id the ID of the Story that we are updating counts on
* @param commentCounts the counts that we are updating
*/
export const updateStoryCounts = (
mongo: Db,
tenantID: string,
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;
}
/**
* retrieveStorySections will return the sections used by stories in the
* database for a given Tenant sorted alphabetically.
*
* @param mongo the database connection to use to retrieve the data
* @param tenantID the ID of the Tenant that we're retrieving data
*/
export async function retrieveStorySections(
mongo: Db,
tenantID: string
): Promise<string[]> {
const results: Array<string | null> = await collection(
mongo
).distinct("metadata.section", { tenantID });
// We perform the type assertion here because we know that after filtering out
// the null entries, the resulting array can not contain null.
return results.filter((section) => section !== null).sort() as string[];
}
@@ -1,4 +1,5 @@
import { getDepth } from "coral-server/models/comment";
import { resolveStoryMode } from "coral-server/models/story";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -12,11 +13,12 @@ import {
export const tagExpertAnswers: IntermediateModerationPhase = ({
author,
story,
tenant,
comment,
}): IntermediatePhaseResult | void => {
if (
// If we're in Q&A mode...
story.settings.mode === GQLSTORY_MODE.QA &&
resolveStoryMode(story.settings, tenant) === GQLSTORY_MODE.QA &&
// And we have experts for this story...
story.settings.expertIDs &&
// And the author is in expert list...
@@ -1,3 +1,4 @@
import { resolveStoryMode } from "coral-server/models/story";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -11,10 +12,10 @@ import {
export const tagUnansweredQuestions: IntermediateModerationPhase = ({
comment,
story,
now,
tenant,
}): IntermediatePhaseResult | void => {
// We only show unanswered tags in Q&A.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
if (resolveStoryMode(story.settings, tenant) !== GQLSTORY_MODE.QA) {
return;
}
+2 -2
View File
@@ -1,7 +1,6 @@
import { Db } from "mongodb";
import { ERROR_TYPES } from "coral-common/errors";
import { Config } from "coral-server/config";
import {
CommentNotFoundError,
@@ -28,6 +27,7 @@ import {
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
import {
resolveStoryMode,
retrieveStory,
Story,
updateStoryLastCommentedAt,
@@ -71,7 +71,7 @@ const markCommentAsAnswered = async (
now: Date
) => {
// We only process this if we're in Q&A mode.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
if (resolveStoryMode(story.settings, tenant) !== GQLSTORY_MODE.QA) {
return;
}