[CORL-159, CORL-160] Stream Config Tab (#2219)

* feat: Implement stream configuration tab

* feat: split profile & configure into separate bundles

* chore: better role logic

* fix+chore: add test cases, implement expectAndFail, refactor tests

* chore: add some comments

* chore: Update src/core/client/framework/lib/form/helpers.tsx

Co-Authored-By: cvle <vinh@wikiwi.io>

* feat: support new graphql mutations/schema

* fix: ci fixes

* fix: improvement to revision loading

* fix: updated some tests

* fix: adapt client to changes

* fix: remove obsolote isClosed in UpdateStory

* ci: increase no_output_timeout for build
This commit is contained in:
Kiwi
2019-03-18 22:07:52 +01:00
committed by GitHub
parent c738d9a355
commit 9eb5afbb2b
128 changed files with 2249 additions and 1081 deletions
+10 -8
View File
@@ -23,14 +23,16 @@ export interface TenantInstallBody {
const TenantInstallBodySchema = Joi.object().keys({
tenant: Joi.object()
.keys({
organizationName: Joi.string().trim(),
organizationURL: Joi.string()
.trim()
.uri(),
organizationContactEmail: Joi.string()
.trim()
.lowercase()
.email(),
organization: Joi.object().keys({
name: Joi.string().trim(),
url: Joi.string()
.trim()
.uri(),
contactEmail: Joi.string()
.trim()
.lowercase()
.email(),
}),
domains: Joi.array().items(
Joi.string()
.trim()
@@ -4,14 +4,25 @@ import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCloseStoryInput,
GQLCreateStoryInput,
GQLMergeStoriesInput,
GQLOpenStoryInput,
GQLRemoveStoryInput,
GQLScrapeStoryInput,
GQLUpdateStoryInput,
GQLUpdateStorySettingsInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Story } from "talk-server/models/story";
import { create, merge, remove, update } from "talk-server/services/stories";
import {
close,
create,
merge,
open,
remove,
update,
updateSettings,
} from "talk-server/services/stories";
import { scrape } from "talk-server/services/stories/scraper";
export const Stories = (ctx: TenantContext) => ({
@@ -33,7 +44,7 @@ export const Stories = (ctx: TenantContext) => ({
),
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
update(ctx.mongo, ctx.tenant, input.id, input.story),
{
"input.story.url": [
ERROR_CODES.STORY_URL_NOT_PERMITTED,
@@ -41,6 +52,14 @@ export const Stories = (ctx: TenantContext) => ({
],
}
),
updateSettings: async (
input: GQLUpdateStorySettingsInput
): Promise<Readonly<Story> | null> =>
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings),
close: (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> =>
close(ctx.mongo, ctx.tenant, input.id),
open: (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> =>
open(ctx.mongo, ctx.tenant, input.id),
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
@@ -59,6 +59,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
story: await ctx.mutators.Stories.update(input),
clientMutationId: input.clientMutationId,
}),
updateStorySettings: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.updateSettings(input),
clientMutationId: input.clientMutationId,
}),
closeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.close(input),
clientMutationId: input.clientMutationId,
}),
openStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.open(input),
clientMutationId: input.clientMutationId,
}),
mergeStories: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.merge(input),
clientMutationId: input.clientMutationId,
@@ -1,3 +1,5 @@
import { defaultsDeep } from "lodash";
import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as story from "talk-server/models/story";
@@ -8,12 +10,14 @@ import { storyModerationInputResolver } from "./ModerationQueues";
export const Story: GQLStoryTypeResolver<story.Story> = {
comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input),
isClosed: (s, input, ctx) => {
const closedAt = getStoryClosedAt(ctx.tenant, s);
const closedAt = getStoryClosedAt(ctx.tenant, s) || null;
return !!closedAt && new Date() >= closedAt;
},
closedAt: (s, input, ctx) => getStoryClosedAt(ctx.tenant, s),
closedAt: (s, input, ctx) => getStoryClosedAt(ctx.tenant, s) || null,
commentActionCounts: s => decodeActionCounts(s.commentCounts.action),
commentCounts: s => s.commentCounts.status,
moderation: (s, input, ctx) => ctx.tenant.moderation,
// Merge tenant settings into the story settings so we can easily inherit the
// options if they exist.
settings: (s, input, ctx) => defaultsDeep({}, s.settings, ctx.tenant),
moderationQueues: storyModerationInputResolver,
};
@@ -0,0 +1,20 @@
import * as story from "talk-server/models/story";
import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
export const StorySettings: GQLStorySettingsTypeResolver<
story.StorySettings
> = {
moderation: (s, input, ctx) => s.moderation || ctx.tenant.moderation,
premodLinksEnable: (s, input, ctx) =>
s.premodLinksEnable || ctx.tenant.premodLinksEnable,
messageBox: s => {
if (s.messageBox) {
return s.messageBox;
}
return {
enabled: false,
};
},
};
@@ -18,6 +18,7 @@ import { Profile } from "./Profile";
import { Query } from "./Query";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Story } from "./Story";
import { StorySettings } from "./StorySettings";
import { User } from "./User";
const Resolvers: GQLResolver = {
@@ -38,6 +39,7 @@ const Resolvers: GQLResolver = {
Query,
RejectCommentPayload,
Story,
StorySettings,
Time,
User,
};
+308 -113
View File
@@ -856,6 +856,27 @@ type CommunityGuidelines {
content: String
}
"""
StoryMessageBox stores settings related to the Story Message Box.
"""
type StoryMessageBox {
"""
enable when true will enable the Message Box on the comment stream.
"""
enabled: Boolean!
"""
icon when set will reference the string for the Message box used by the
Message Box.
"""
icon: String
"""
content when set contains the actual markup for the Message Box.
"""
content: String
}
################################################################################
## Settings
################################################################################
@@ -870,6 +891,51 @@ enum LOCALES {
de
}
"""
CloseCommenting contains settings related to the automatic closing of commenting
on Stories.
"""
type CloseCommenting {
"""
auto when true will configure the automatic close on each story as they are
created based on the current configured timeout option.
"""
auto: Boolean!
"""
timeout is the amount of seconds from the `createdAt` timestamp that a given
story will be considered closed.
"""
timeout: Int!
"""
message when provided will be the message that shows when the comment stream
is closed for commenting.
"""
message: String
}
"""
Organization stores information about the organization behind this specific
instance of Talk.
"""
type Organization {
"""
name is the name of the organization.
"""
name: String!
"""
contactEmail is the email of the organization.
"""
contactEmail: String!
"""
url is the URL to the organizations home page.
"""
url: String!
}
"""
Settings stores the global settings for a given Tenant.
"""
@@ -887,7 +953,7 @@ type Settings {
"""
domains will return a given list of permitted domains.
"""
domains: [String!] @auth(roles: [ADMIN])
domains: [String!]! @auth(roles: [ADMIN])
"""
locale is the specified locale for this Tenant.
@@ -899,60 +965,27 @@ type Settings {
"""
moderation: MODERATION_MODE @auth(roles: [ADMIN])
"""
Enables a requirement for email confirmation before a user can login.
"""
requireEmailConfirmation: Boolean!
"""
communityGuidelines will be shown in the comments stream.
"""
communityGuidelines: CommunityGuidelines!
"""
questionBoxEnable will enable the Question Box's content to be visible above
the comment box.
"""
questionBoxEnable: Boolean!
"""
questionBoxContent is the content of the Question Box.
"""
questionBoxContent: String
"""
questionBoxIcon is the icon for the Question Box.
"""
questionBoxIcon: String
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean @auth(roles: [ADMIN])
"""
autoCloseStream when true will auto close the stream when the `closeTimeout`
amount of seconds have been reached.
closeCommenting contains settings related to the automatic closing of commenting on
Stories.
"""
autoCloseStream: Boolean! @auth(roles: [ADMIN])
closeCommenting: CloseCommenting!
"""
customCSSURL is the URL of the custom CSS used to display on the frontend.
"""
customCSSURL: String
"""
closedTimeout is the amount of time (in seconds) from the createdAt timestamp
that a given story will be considered closed.
"""
closedTimeout: Int!
"""
closedMessage is the message shown to the user when the given Story is
closed.
"""
closedMessage: String
"""
disableCommenting will disable commenting site-wide.
"""
@@ -970,14 +1003,10 @@ type Settings {
charCount: CharCount!
"""
organizationName is the name of the organization.
organization stores information about the organization behind this specific
instance of Talk.
"""
organizationName: String!
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String!
organization: Organization!
"""
email is the set of credentials and settings associated with the organization.
@@ -1567,6 +1596,23 @@ type StoryMetadata {
section: String
}
type StorySettings {
"""
moderation determines whether or not this is a PRE or POST moderated story.
"""
moderation: MODERATION_MODE!
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean!
"""
messageBox stores settings related to the Story Message Box.
"""
messageBox: StoryMessageBox!
}
"""
STORY_STATUS represents filtering states that a Story can be in.
"""
@@ -1650,9 +1696,10 @@ type Story {
createdAt: Time!
"""
moderation determines whether or not this is a PRE or POST moderated story.
settings is the set of Settings on a Story that inherit from the global
settings.
"""
moderation: MODERATION_MODE!
settings: StorySettings!
}
"""
@@ -1710,7 +1757,7 @@ type Query {
after: Cursor
storyID: ID
status: COMMENT_STATUS
): CommentsConnection @auth(roles: [ADMIN, MODERATOR])
): CommentsConnection! @auth(roles: [ADMIN, MODERATOR])
"""
story is a specific article that can be identified by either an ID or a URL.
@@ -1724,7 +1771,7 @@ type Query {
first: Int = 10
after: Cursor
status: STORY_STATUS
): StoriesConnection @auth(roles: [ADMIN, MODERATOR])
): StoriesConnection! @auth(roles: [ADMIN, MODERATOR])
"""
user will return the user referenced by their ID.
@@ -2268,6 +2315,43 @@ input SettingsDisableCommentingInput {
message: String
}
input SettingsOrganizationInput {
"""
name is the name of the organization.
"""
name: String
"""
contactEmail is the email of the organization.
"""
contactEmail: String
"""
url is the URL to the organizations home page.
"""
url: String
}
input SettingsCloseCommentingInput {
"""
auto when true will configure the automatic close on each story as they are
created based on the current configured timeout option.
"""
auto: Boolean
"""
timeout is the amount of seconds from the `createdAt` timestamp that a given
story will be considered closed.
"""
timeout: Int
"""
message when provided will be the message that shows when the comment stream
is closed for commenting.
"""
message: String
}
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
@@ -2282,60 +2366,21 @@ input SettingsInput {
"""
moderation: MODERATION_MODE
"""
Enables a requirement for email confirmation before a user can login.
"""
requireEmailConfirmation: Boolean
"""
communityGuidelines will be shown in the comments stream.
"""
communityGuidelines: SettingsCommunityGuidelinesInput
"""
questionBoxEnable will enable the Question Box's content to be visible above
the comment box.
"""
questionBoxEnable: Boolean
"""
questionBoxContent is the content of the Question Box.
"""
questionBoxContent: String
"""
questionBoxIcon is the icon for the Question Box.
"""
questionBoxIcon: String
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean
"""
autoCloseStream when true will auto close the stream when the `closeTimeout`
amount of seconds have been reached.
"""
autoCloseStream: Boolean
"""
customCSSURL is the URL of the custom CSS used to display on the frontend.
"""
customCSSURL: String
"""
closedTimeout is the amount of seconds from the createdAt timestamp that a
given story will be considered closed.
"""
closedTimeout: Int
"""
closedMessage is the message shown to the user when the given Story is
closed.
"""
closedMessage: String
"""
disableCommenting will disable commenting site-wide.
"""
@@ -2348,14 +2393,16 @@ input SettingsInput {
editCommentWindowLength: Int
"""
organizationName is the name of the organization.
organization stores information about the organization behind this specific
instance of Talk.
"""
organizationName: String
organization: SettingsOrganizationInput
"""
organizationContactEmail is the email of the organization.
closeCommenting contains settings related to the automatic closing of commenting on
Stories.
"""
organizationContactEmail: String
closeCommenting: SettingsCloseCommentingInput
"""
wordList will return a given list of words.
@@ -2726,11 +2773,6 @@ input UpdateStory {
be scraped, but can be provided here.
"""
metadata: StoryMetadataInput
"""
isClosed is true when the Story should be closed for commenting.
"""
isClosed: Boolean
}
input UpdateStoryInput {
@@ -2764,6 +2806,140 @@ type UpdateStoryPayload {
clientMutationId: String!
}
##################
## updateStorySettings
##################
"""
StoryMessageBoxInput stores settings related to the Story Message Box.
"""
input StoryMessageBoxInput {
"""
enable when true will enable the Message Box on the comment stream.
"""
enabled: Boolean
"""
icon when set will reference the string for the Message box used by the
Message Box.
"""
icon: String
"""
content when set contains the actual markup for the Message Box.
"""
content: String
}
"""
UpdateStorySettings is the input required to update a Story's Settings.
"""
input UpdateStorySettings {
"""
moderation determines whether or not this is a PRE or POST moderated story.
"""
moderation: MODERATION_MODE
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean
"""
messageBox stores settings related to the Story Message Box.
"""
messageBox: StoryMessageBoxInput
}
input UpdateStorySettingsInput {
"""
id is the identifier of the Story used either when the Story was created via
the API or from Talk when it was lazily created.
"""
id: ID!
"""
settings contains the fields on the story settings that should be updated. Any
fields not specified will not be changed.
"""
settings: UpdateStorySettings!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateStorySettingsPayload {
"""
story is the Story that was possibly updated.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## closeStory
##################
input CloseStoryInput {
"""
id is the identifier of the Story used either when the Story was created via
the API or from Talk when it was lazily created.
"""
id: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CloseStoryPayload {
"""
story is the Story that was possibly updated.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## openStory
##################
input OpenStoryInput {
"""
id is the identifier of the Story used either when the Story was created via
the API or from Talk when it was lazily created.
"""
id: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type OpenStoryPayload {
"""
story is the Story that was possibly updated.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## mergeStories
##################
@@ -3269,7 +3445,7 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
createComment(input: CreateCommentInput!): CreateCommentPayload! @auth
"""
createCommentReply will create a Comment as the current logged in User that is
@@ -3277,25 +3453,25 @@ type Mutation {
"""
createCommentReply(
input: CreateCommentReplyInput!
): CreateCommentReplyPayload @auth
): CreateCommentReplyPayload! @auth
"""
editComment will allow the author of a comment to change the body within the
time allotment.
"""
editComment(input: EditCommentInput!): EditCommentPayload @auth
editComment(input: EditCommentInput!): EditCommentPayload! @auth
"""
updateSettings will update the Settings for the given Tenant.
"""
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
regenerateSSOKey will regenerate the SSO key used to sign secrets. This will
invalidate any existing user sessions.
"""
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
@auth(roles: [ADMIN])
"""
@@ -3334,77 +3510,96 @@ type Mutation {
createCommentFlag will create a Flag authored by the current logged in User on
a given Comment.
"""
createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload
createCommentFlag(input: CreateCommentFlagInput!): CreateCommentFlagPayload!
@auth
"""
createStory will create the provided Story.
"""
createStory(input: CreateStoryInput!): CreateStoryPayload
createStory(input: CreateStoryInput!): CreateStoryPayload!
@auth(roles: [ADMIN])
"""
updateStory will update the given Story.
"""
updateStory(input: UpdateStoryInput!): UpdateStoryPayload
updateStory(input: UpdateStoryInput!): UpdateStoryPayload!
@auth(roles: [ADMIN])
"""
updateStory will update the given Story's settings.
"""
updateStorySettings(
input: UpdateStorySettingsInput!
): UpdateStorySettingsPayload! @auth(roles: [ADMIN, MODERATOR])
"""
closeStory will close the given story for commenting.
"""
closeStory(input: CloseStoryInput!): CloseStoryPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
openStory will open the given story for commenting.
"""
openStory(input: OpenStoryInput!): OpenStoryPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
mergeStories will merge two stories together, merging their comment streams.
This operation is irreversible.
"""
mergeStories(input: MergeStoriesInput!): MergeStoriesPayload
mergeStories(input: MergeStoriesInput!): MergeStoriesPayload!
@auth(roles: [ADMIN])
"""
removeStory will remove the given Story.
"""
removeStory(input: RemoveStoryInput!): RemoveStoryPayload
removeStory(input: RemoveStoryInput!): RemoveStoryPayload!
@auth(roles: [ADMIN])
"""
scrapeStory will scrape the given Story and update the scraped metadata.
"""
scrapeStory(input: ScrapeStoryInput!): ScrapeStoryPayload
scrapeStory(input: ScrapeStoryInput!): ScrapeStoryPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
acceptComment will mark the Comment as ACCEPTED.
"""
acceptComment(input: AcceptCommentInput!): AcceptCommentPayload
acceptComment(input: AcceptCommentInput!): AcceptCommentPayload!
@auth(roles: [MODERATOR, ADMIN])
"""
rejectComment will mark the Comment as REJECTED.
"""
rejectComment(input: RejectCommentInput!): RejectCommentPayload
rejectComment(input: RejectCommentInput!): RejectCommentPayload!
@auth(roles: [MODERATOR, ADMIN])
"""
setUsername will set the username on the current User if they have not set one
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL])
"""
setEmail will set the email address on the current User if they have not set
one already. This mutation will fail if the email address is already set.
"""
setEmail(input: SetEmailInput!): SetEmailPayload
setEmail(input: SetEmailInput!): SetEmailPayload!
@auth(permit: [MISSING_EMAIL])
"""
setPassword will set the password on the current User if they have not set
one already. This mutation will fail if the password is already set.
"""
setPassword(input: SetPasswordInput!): SetPasswordPayload @auth
setPassword(input: SetPasswordInput!): SetPasswordPayload! @auth
"""
updatePassword allows the current logged in User to change their password if
they already have one associated with them.
"""
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload @auth
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload! @auth
"""
createToken allows an administrator to create a Token based on the current
+27 -78
View File
@@ -1,46 +1,20 @@
import { Omit } from "talk-common/types";
import {
GQLCharCount,
GQLDisableCommenting,
GQLEmail,
GQLExternalIntegrations,
GQLAuth,
GQLFacebookAuthIntegration,
GQLGoogleAuthIntegration,
GQLKarma,
GQLLocalAuthIntegration,
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
GQLReactionConfiguration,
GQLSettings,
GQLSSOAuthIntegration,
GQLWordList,
} from "talk-server/graph/tenant/schema/__generated__/types";
export interface ModerationSettings {
export interface GlobalModerationSettings {
moderation: GQLMODERATION_MODE;
requireEmailConfirmation: boolean;
communityGuidelines: {
enabled: boolean;
content?: string;
};
questionBoxEnable: boolean;
questionBoxIcon?: string;
questionBoxContent?: string;
premodLinksEnable: boolean;
autoCloseStream: boolean;
/**
* closedTimeout is the amount of seconds from the createdAt timestamp that a
* given story will be considered closed.
*/
closedTimeout: number;
closedMessage?: string;
disableCommenting: GQLDisableCommenting;
charCount: GQLCharCount;
}
export type LocalAuthIntegration = GQLLocalAuthIntegration;
export type SSOAuthIntegration = GQLSSOAuthIntegration;
export type OIDCAuthIntegration = Omit<
GQLOIDCAuthIntegration,
"callbackURL" | "redirectURL"
@@ -57,63 +31,38 @@ export type FacebookAuthIntegration = Omit<
>;
export interface AuthIntegrations {
local: LocalAuthIntegration;
sso: SSOAuthIntegration;
local: GQLLocalAuthIntegration;
sso: GQLSSOAuthIntegration;
oidc: OIDCAuthIntegration;
google: GoogleAuthIntegration;
facebook: FacebookAuthIntegration;
}
export interface Auth {
export type Auth = Omit<GQLAuth, "integrations"> & {
/**
* integrations are the set of configurations for the variations of
* authentication solutions.
*/
integrations: AuthIntegrations;
}
};
export interface Settings extends ModerationSettings {
/**
* customCSSURL is the URL that can be specified by the Tenant to describe a
* URL that contains custom styles to be applied to the Stream.
*/
customCSSURL?: string;
/**
* editCommentWindowLength is the length of time (in seconds) after a comment
* is posted that it can still be edited by the author.
*/
editCommentWindowLength: number;
/**
* email is the set of credentials and settings associated with the
* Tenant.
*/
email: GQLEmail;
/**
* karma is the set of settings related to how user Trust and Karma are
* handled.
*/
karma: GQLKarma;
/**
* wordList stores all the banned/suspect words.
*/
wordList: GQLWordList;
/**
* Set of configured authentication integrations.
*/
auth: Auth;
/**
* Various integrations with external services.
*/
integrations: GQLExternalIntegrations;
/**
* reaction specifies the configuration for reactions.
*/
reaction: GQLReactionConfiguration;
}
export type Settings = GlobalModerationSettings &
Pick<
GQLSettings,
| "closeCommenting"
| "disableCommenting"
| "charCount"
| "email"
| "karma"
| "wordList"
| "integrations"
| "reaction"
| "editCommentWindowLength"
| "customCSSURL"
| "communityGuidelines"
> & {
/**
* Set of configured authentication integrations.
*/
auth: Auth;
};
+87 -11
View File
@@ -1,15 +1,14 @@
import { isNull, omitBy } from "lodash";
import { Db, MongoError } from "mongodb";
import uuid from "uuid";
import { Omit } from "talk-common/types";
import { DeepPartial, Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { DuplicateStoryURLError } from "talk-server/errors";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import Query, {
createConnectionOrderVariants,
createIndexFactory,
} from "talk-server/models/helpers/query";
import { ModerationSettings } from "talk-server/models/settings";
import {
GQLStoryMetadata,
GQLStorySettings,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { TenantResource } from "talk-server/models/tenant";
import {
@@ -17,6 +16,10 @@ import {
ConnectionInput,
resolveConnection,
} from "../helpers/connection";
import Query, {
createConnectionOrderVariants,
createIndexFactory,
} from "../helpers/query";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
@@ -30,6 +33,10 @@ function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
export type StorySettings = DeepPartial<GQLStorySettings>;
export type StoryMetadata = GQLStoryMetadata;
export interface Story extends TenantResource {
readonly id: string;
@@ -41,7 +48,7 @@ export interface Story extends TenantResource {
/**
* metadata stores the scraped metadata from the Story page.
*/
metadata?: GQLStoryMetadata;
metadata?: StoryMetadata;
/**
* scrapedAt is the Time that the Story had it's metadata scraped at.
@@ -57,7 +64,7 @@ export interface Story extends TenantResource {
* settings provides a point where the settings can be overridden for a
* specific Story.
*/
settings?: Partial<ModerationSettings>;
settings: StorySettings;
/**
* closedAt is the date that the Story was forced closed at, or false to
@@ -69,6 +76,11 @@ export interface Story extends TenantResource {
* createdAt is the date that the Story was added to the Talk database.
*/
createdAt: Date;
/**
* premodLinksEnable will put all comments that contain links into premod.
*/
premodLinksEnable?: boolean;
}
export async function createStoryIndexes(mongo: Db) {
@@ -117,6 +129,7 @@ export async function upsertStory(
status: createEmptyCommentStatusCounts(),
moderationQueue: createEmptyCommentModerationQueueCounts(),
},
settings: {},
},
};
@@ -196,6 +209,7 @@ export async function createStory(
moderationQueue: createEmptyCommentModerationQueueCounts(),
status: createEmptyCommentStatusCounts(),
},
settings: {},
};
try {
@@ -291,13 +305,75 @@ export async function updateStory(
// 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) {
// TODO: (wyattjoh) return better error
throw new Error("story with this url already exists");
throw new DuplicateStoryURLError(input.url);
}
throw err;
}
}
export type UpdateStorySettingsInput = StorySettings;
export async function updateStorySettings(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStorySettingsInput
) {
// Only update fields that have been updated.
const update = {
$set: {
...omitBy(dotize({ settings: input }, { embedArrays: true }), isNull),
// Always update the updated at time.
updatedAt: new Date(),
},
};
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) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: false,
// Always update the updated at time.
updatedAt: new Date(),
},
},
// 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) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: new Date(),
// Always update the updated at time.
updatedAt: new Date(),
},
},
// 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({
+21 -29
View File
@@ -5,7 +5,10 @@ import uuid from "uuid";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLMODERATION_MODE,
GQLSettings,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "talk-server/models/helpers/query";
import { Settings } from "talk-server/models/settings";
@@ -13,6 +16,10 @@ function collection(mongo: Db) {
return mongo.collection<Readonly<Tenant>>("tenants");
}
/**
* TenantResource references a given resource that should be owned by a specific
* Tenant.
*/
export interface TenantResource {
/**
* tenantID is the reference to the specific Tenant that owns this particular
@@ -21,29 +28,21 @@ export interface TenantResource {
readonly tenantID: string;
}
/**
* Tenant describes a given Tenant on Talk that has Stories, Comments, and Users.
*/
export interface Tenant extends Settings {
export interface TenantSettings
extends Pick<GQLSettings, "domain" | "domains" | "organization"> {
readonly id: string;
// Domain is set when the tenant is created, and is used to retrieve the
// specific tenant that the API request pertains to.
domain: string;
// domains is the list of domains that are allowed to have the iframe load on.
domains: string[];
/**
* locale is the specified locale for this Tenant.
*/
locale: LanguageCode;
organizationName: string;
organizationURL: string;
organizationContactEmail: string;
}
/**
* Tenant describes a given Tenant on Talk that has Stories, Comments, and Users.
*/
export type Tenant = Settings & TenantSettings;
export async function createTenantIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
@@ -61,12 +60,7 @@ export async function createTenantIndexes(mongo: Db) {
*/
export type CreateTenantInput = Pick<
Tenant,
| "domain"
| "domains"
| "locale"
| "organizationName"
| "organizationURL"
| "organizationContactEmail"
"domain" | "domains" | "locale" | "organization"
>;
/**
@@ -83,22 +77,20 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
// Default to post moderation.
moderation: GQLMODERATION_MODE.POST,
// Email confirmation is default off.
requireEmailConfirmation: false,
communityGuidelines: {
enabled: false,
content: "",
},
questionBoxEnable: false,
premodLinksEnable: false,
autoCloseStream: false,
closeCommenting: {
auto: false,
// 2 weeks timeout.
timeout: 60 * 60 * 24 * 7 * 2,
},
disableCommenting: {
enabled: false,
},
// 2 weeks timeout.
closedTimeout: 60 * 60 * 24 * 7 * 2,
// 30 seconds edit window length.
editCommentWindowLength: 30,
@@ -5,16 +5,13 @@ import {
CommentBodyExceedsMaxLengthError,
CommentBodyTooShortError,
} from "talk-server/errors";
import { ModerationSettings } from "talk-server/models/settings";
import { Settings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/pipeline";
const testCharCount = (
settings: Partial<ModerationSettings>,
length: number
) => {
const testCharCount = (settings: Partial<Settings>, length: number) => {
if (settings.charCount && settings.charCount.enabled) {
if (!isNil(settings.charCount.min)) {
if (length < settings.charCount.min) {
@@ -1,11 +1,11 @@
import { CommentingDisabledError } from "talk-server/errors";
import { ModerationSettings } from "talk-server/models/settings";
import { Settings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/pipeline";
const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
const testDisabledCommenting = (settings: Partial<Settings>) =>
settings.disableCommenting && settings.disableCommenting.enabled;
export const commentingDisabled: IntermediateModerationPhase = ({
@@ -4,14 +4,14 @@ import {
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import { Comment } from "talk-server/models/comment";
import { ModerationSettings } from "talk-server/models/settings";
import { GlobalModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/pipeline";
const testPremodLinksEnable = (
settings: Partial<ModerationSettings>,
settings: Partial<GlobalModerationSettings>,
comment: Pick<Comment, "metadata">
) =>
settings.premodLinksEnable && comment.metadata && comment.metadata.linkCount;
@@ -2,13 +2,13 @@ import {
GQLCOMMENT_STATUS,
GQLMODERATION_MODE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationSettings } from "talk-server/models/settings";
import { GlobalModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/pipeline";
const testModerationMode = (settings: Partial<ModerationSettings>) =>
const testModerationMode = (settings: Partial<GlobalModerationSettings>) =>
settings.moderation === GQLMODERATION_MODE.PRE;
// This phase checks to see if the settings have premod enabled, if they do,
@@ -16,7 +16,9 @@ describe("storyClosed", () => {
storyClosed({
story: {} as ModerationPhaseContext["story"],
tenant: { autoCloseStream: true } as ModerationPhaseContext["tenant"],
tenant: {
closeCommenting: { auto: true },
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
});
@@ -25,8 +27,10 @@ describe("storyClosed", () => {
storyClosed({
story: { createdAt: new Date() } as ModerationPhaseContext["story"],
tenant: {
autoCloseStream: true,
closedTimeout: -6000,
closeCommenting: {
auto: true,
timeout: -6000,
},
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
@@ -44,7 +48,9 @@ describe("storyClosed", () => {
.plus(60000)
.toJSDate(),
} as ModerationPhaseContext["story"],
tenant: {} as ModerationPhaseContext["tenant"],
tenant: {
closeCommenting: { auto: true },
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
})
@@ -53,7 +59,9 @@ describe("storyClosed", () => {
expect(
storyClosed({
story: {} as ModerationPhaseContext["story"],
tenant: {} as ModerationPhaseContext["tenant"],
tenant: {
closeCommenting: { auto: true },
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
})
+30 -3
View File
@@ -22,11 +22,13 @@ import {
} from "talk-server/models/comment";
import {
calculateTotalCommentCount,
closeStory,
createStory,
CreateStoryInput,
findOrCreateStory,
FindOrCreateStoryInput,
mergeCommentStatusCount,
openStory,
removeStories,
removeStory,
retrieveManyStories,
@@ -36,6 +38,8 @@ import {
updateStoryActionCounts,
updateStoryCommentStatusCount,
UpdateStoryInput,
updateStorySettings,
UpdateStorySettingsInput,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
@@ -227,6 +231,24 @@ export async function update(
return updateStory(mongo, tenant.id, storyID, input);
}
export type UpdateStorySettings = UpdateStorySettingsInput;
export async function updateSettings(
mongo: Db,
tenant: Tenant,
storyID: string,
input: UpdateStorySettings
) {
return updateStorySettings(mongo, tenant.id, storyID, input);
}
export async function open(mongo: Db, tenant: Tenant, storyID: string) {
return openStory(mongo, tenant.id, storyID);
}
export async function close(mongo: Db, tenant: Tenant, storyID: string) {
return closeStory(mongo, tenant.id, storyID);
}
export async function merge(
mongo: Db,
@@ -339,7 +361,7 @@ export async function merge(
}
export function getStoryClosedAt(
tenant: Pick<Tenant, "autoCloseStream" | "closedTimeout">,
tenant: Pick<Tenant, "closeCommenting">,
story: Pick<Story, "closedAt" | "createdAt">
): Story["closedAt"] {
// Try to get the closedAt time from the story.
@@ -347,16 +369,21 @@ export function getStoryClosedAt(
return story.closedAt;
}
// Check to see if the story has been forced open again.
if (story.closedAt === false) {
return false;
}
// If the story hasn't already been closed, then check to see if the Tenant
// has the auto close stream enabled.
if (tenant.autoCloseStream && tenant.closedTimeout) {
if (tenant.closeCommenting.auto) {
// Auto-close stream has been enabled, convert the createdAt time into the
// closedAt time by adding the closedTimeout.
return (
DateTime.fromJSDate(story.createdAt)
// closedTimeout is in seconds, so multiply by 1000 to get
// milliseconds.
.plus(tenant.closedTimeout * 1000)
.plus(tenant.closeCommenting.timeout * 1000)
.toJSDate()
);
}