diff --git a/package-lock.json b/package-lock.json index f43b5a18b..72279b681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@coralproject/talk", - "version": "6.2.2", + "version": "6.2.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 643624df2..c0194fb24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coralproject/talk", - "version": "6.2.2", + "version": "6.2.3", "author": "The Coral Project", "homepage": "https://coralproject.net/", "sideEffects": [ diff --git a/src/core/server/graph/resolvers/StorySettings.ts b/src/core/server/graph/resolvers/StorySettings.ts index d0100efb4..03a2c152d 100644 --- a/src/core/server/graph/resolvers/StorySettings.ts +++ b/src/core/server/graph/resolvers/StorySettings.ts @@ -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 = { }; }, // 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); diff --git a/src/core/server/models/story/helpers.ts b/src/core/server/models/story/helpers.ts index 8e9155a8c..9fe6495b8 100644 --- a/src/core/server/models/story/helpers.ts +++ b/src/core/server/models/story/helpers.ts @@ -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 +) { + 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; +} diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index d7b73e557..179fd50a6 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -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; - -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; - - /** - * 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 { - // 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 { - 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 -> & { - 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, - "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; - -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; - -export async function retrieveStoryConnection( - mongo: Db, - tenantID: string, - input: StoryConnectionInput -): Promise>>> { - // 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 -): Promise>>> { - // 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 -) => 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 { - const results: Array = 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[]; -} diff --git a/src/core/server/models/story/story.ts b/src/core/server/models/story/story.ts new file mode 100644 index 000000000..d7b73e557 --- /dev/null +++ b/src/core/server/models/story/story.ts @@ -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; + +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; + + /** + * 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 { + // 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 { + 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 +> & { + 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, + "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; + +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; + +export async function retrieveStoryConnection( + mongo: Db, + tenantID: string, + input: StoryConnectionInput +): Promise>>> { + // 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 +): Promise>>> { + // 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 +) => 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 { + const results: Array = 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[]; +} diff --git a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts index 7426c5929..939b65a28 100644 --- a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts +++ b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts @@ -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... diff --git a/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts index 9789bf32b..b1d9e9202 100644 --- a/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts +++ b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts @@ -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; } diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index 048cb3e6e..08821d732 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -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; }