[next] Story Mutations (#2055)

*  feat: added mutations for removing + scraping stories

- added mutation for removing stories
- added mutation for scraping stories
- renamed all delete* to remove* to avoid reserved keyword collision

* fix: linting

* feat: implemented createStory

* feat: added support for createStory

* feat: improved Story deletion

- Deletes comments on the deleted Story
- Deletes actions of comments that were deleted via new
  `root_item_id` parameter for Comment Action's
- Added logging around critical components of the remove
  process

* feat: implemented mergeStory

- Added new merge function for root actions
- Added new merge function for story comments
- Added new merge function for comment status counts
- Added new remove stories function

* fix: fixed story creation method

* fix: added action count merging

* fix: removed outdated file

* fix: removed queue that got duplicated via rebase

* fix: prevent error when action counts are empty
This commit is contained in:
Wyatt Johnson
2018-11-02 23:18:12 +00:00
committed by GitHub
parent 7aa9a6b4f7
commit ca12b73b55
32 changed files with 1123 additions and 232 deletions
@@ -6,12 +6,12 @@ import {
createMutationContainer,
} from "talk-framework/lib/relay";
import { DeleteCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/DeleteCommentReactionMutation.graphql";
import { CreateCommentReactionInput } from "./CreateCommentReactionMutation";
import { RemoveCommentReactionMutation as MutationTypes } from "talk-stream/__generated__/RemoveCommentReactionMutation.graphql";
import { CreateCommentReactionInput } from "talk-stream/mutations/CreateCommentReactionMutation";
const mutation = graphql`
mutation DeleteCommentReactionMutation($input: CreateCommentReactionInput!) {
deleteCommentReaction(input: $input) {
mutation RemoveCommentReactionMutation($input: CreateCommentReactionInput!) {
removeCommentReaction(input: $input) {
comment {
...ReactionButtonContainer_comment
}
@@ -36,7 +36,7 @@ function commit(environment: Environment, input: CreateCommentReactionInput) {
},
},
optimisticResponse: {
deleteCommentReaction: {
removeCommentReaction: {
comment: {
id: input.commentID,
myActionPresence: {
@@ -54,11 +54,11 @@ function commit(environment: Environment, input: CreateCommentReactionInput) {
});
}
export const withDeleteCommentReactionMutation = createMutationContainer(
"deleteCommentReaction",
export const withRemoveCommentReactionMutation = createMutationContainer(
"removeCommentReaction",
commit
);
export type DeleteCommentReactionMutation = (
export type RemoveCommentReactionMutation = (
input: CreateCommentReactionInput
) => Promise<MutationTypes["response"]["deleteCommentReaction"]>;
) => Promise<MutationTypes["response"]["removeCommentReaction"]>;
+3 -3
View File
@@ -32,6 +32,6 @@ export {
CreateCommentReactionInput,
} from "./CreateCommentReactionMutation";
export {
withDeleteCommentReactionMutation,
DeleteCommentReactionMutation,
} from "./DeleteCommentReactionMutation";
withRemoveCommentReactionMutation,
RemoveCommentReactionMutation,
} from "./RemoveCommentReactionMutation";
@@ -7,9 +7,9 @@ import { ReactionButtonContainer_settings as SettingsData } from "talk-stream/__
import {
CreateCommentReactionMutation,
DeleteCommentReactionMutation,
RemoveCommentReactionMutation,
withCreateCommentReactionMutation,
withDeleteCommentReactionMutation,
withRemoveCommentReactionMutation,
} from "talk-stream/mutations";
import ReactionButton from "talk-stream/tabs/comments/components/ReactionButton";
@@ -20,7 +20,7 @@ import {
interface ReactionButtonContainerProps {
createCommentReaction: CreateCommentReactionMutation;
deleteCommentReaction: DeleteCommentReactionMutation;
removeCommentReaction: RemoveCommentReactionMutation;
comment: CommentData;
settings: SettingsData;
me: MeData | null;
@@ -41,13 +41,13 @@ class ReactionButtonContainer extends React.Component<
commentID: this.props.comment.id,
};
const { createCommentReaction, deleteCommentReaction } = this.props;
const { createCommentReaction, removeCommentReaction } = this.props;
const reacted =
this.props.comment.myActionPresence &&
this.props.comment.myActionPresence.reaction;
return reacted
? deleteCommentReaction(input)
? removeCommentReaction(input)
: createCommentReaction(input);
};
public render() {
@@ -79,7 +79,7 @@ class ReactionButtonContainer extends React.Component<
}
export default withShowAuthPopupMutation(
withDeleteCommentReactionMutation(
withRemoveCommentReactionMutation(
withCreateCommentReactionMutation(
withFragmentContainer<ReactionButtonContainerProps>({
me: graphql`
+1 -1
View File
@@ -14,8 +14,8 @@ import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import { TaskQueue } from "talk-server/queue";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { TaskQueue } from "talk-server/services/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { accessLogger, errorLogger } from "./middleware/logging";
@@ -4,7 +4,7 @@ import { Db } from "mongodb";
import { Config } from "talk-common/config";
import TenantContext from "talk-server/graph/tenant/context";
import { TaskQueue } from "talk-server/services/queue";
import { TaskQueue } from "talk-server/queue";
import { Request } from "talk-server/types/express";
export interface TenantContextMiddlewareOptions {
+1 -1
View File
@@ -4,7 +4,7 @@ import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { TaskQueue } from "talk-server/services/queue";
import { TaskQueue } from "talk-server/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
@@ -7,8 +7,8 @@ import {
retrieveManyStories,
Story,
} from "talk-server/models/story";
import { scraper } from "talk-server/services/queue/tasks/scraper";
import { findOrCreate } from "talk-server/services/stories";
import { scraper } from "talk-server/services/stories/scraper";
export default (ctx: TenantContext) => ({
findOrCreate: (input: FindOrCreateStoryInput) =>
@@ -4,19 +4,19 @@ import {
GQLCreateCommentFlagInput,
GQLCreateCommentInput,
GQLCreateCommentReactionInput,
GQLDeleteCommentDontAgreeInput,
GQLDeleteCommentFlagInput,
GQLDeleteCommentReactionInput,
GQLEditCommentInput,
GQLRemoveCommentDontAgreeInput,
GQLRemoveCommentFlagInput,
GQLRemoveCommentReactionInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { create, edit } from "talk-server/services/comments";
import {
createDontAgree,
createFlag,
createReaction,
deleteDontAgree,
deleteFlag,
deleteReaction,
removeDontAgree,
removeFlag,
removeReaction,
} from "talk-server/services/comments/actions";
export default (ctx: TenantContext) => ({
@@ -48,16 +48,16 @@ export default (ctx: TenantContext) => ({
createReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
deleteReaction: (input: GQLDeleteCommentReactionInput) =>
deleteReaction(ctx.mongo, ctx.tenant, ctx.user!, {
removeReaction: (input: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
createDontAgree: (input: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
deleteDontAgree: (input: GQLDeleteCommentDontAgreeInput) =>
deleteDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
removeDontAgree: (input: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
createFlag: (input: GQLCreateCommentFlagInput) =>
@@ -65,8 +65,8 @@ export default (ctx: TenantContext) => ({
item_id: input.commentID,
reason: input.reason,
}),
deleteFlag: (input: GQLDeleteCommentFlagInput) =>
deleteFlag(ctx.mongo, ctx.tenant, ctx.user!, {
removeFlag: (input: GQLRemoveCommentFlagInput) =>
removeFlag(ctx.mongo, ctx.tenant, ctx.user!, {
item_id: input.commentID,
}),
});
@@ -2,8 +2,10 @@ import TenantContext from "talk-server/graph/tenant/context";
import Comment from "./comment";
import Settings from "./settings";
import Story from "./story";
export default (ctx: TenantContext) => ({
Comment: Comment(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
});
@@ -3,15 +3,15 @@ import { isNull, omitBy } from "lodash";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateOIDCAuthIntegrationInput,
GQLDeleteOIDCAuthIntegrationInput,
GQLRemoveOIDCAuthIntegrationInput,
GQLSettingsInput,
GQLUpdateOIDCAuthIntegrationInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import {
createOIDCAuthIntegration,
deleteOIDCAuthIntegration,
regenerateSSOKey,
removeOIDCAuthIntegration,
update,
updateOIDCAuthIntegration,
} from "talk-server/services/tenant";
@@ -38,6 +38,6 @@ export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({
input.id,
input.configuration
),
deleteOIDCAuthIntegration: (input: GQLDeleteOIDCAuthIntegrationInput) =>
deleteOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id),
removeOIDCAuthIntegration: (input: GQLRemoveOIDCAuthIntegrationInput) =>
removeOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id),
});
@@ -0,0 +1,32 @@
import { isNull, omitBy } from "lodash";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateStoryInput,
GQLMergeStoriesInput,
GQLRemoveStoryInput,
GQLScrapeStoryInput,
GQLUpdateStoryInput,
} 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 { scrape } from "talk-server/services/stories/scraper";
export default (ctx: TenantContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
create(
ctx.mongo,
ctx.tenant,
input.story.id,
input.story.url,
omitBy(input.story, isNull)
),
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs),
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.tenant.id, input.id),
});
@@ -27,24 +27,24 @@ const Mutation: GQLMutationTypeResolver<void> = {
comment: await ctx.mutators.Comment.createReaction(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteReaction(input),
removeCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeReaction(input),
clientMutationId: input.clientMutationId,
}),
createCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createDontAgree(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteDontAgree(input),
removeCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeDontAgree(input),
clientMutationId: input.clientMutationId,
}),
createCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createFlag(input),
clientMutationId: input.clientMutationId,
}),
deleteCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.deleteFlag(input),
removeCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeFlag(input),
clientMutationId: input.clientMutationId,
}),
regenerateSSOKey: async (source, { input }, ctx) => ({
@@ -75,8 +75,8 @@ const Mutation: GQLMutationTypeResolver<void> = {
clientMutationId: input.clientMutationId,
};
},
deleteOIDCAuthIntegration: async (source, { input }, ctx) => {
const result = await ctx.mutators.Settings.deleteOIDCAuthIntegration(input);
removeOIDCAuthIntegration: async (source, { input }, ctx) => {
const result = await ctx.mutators.Settings.removeOIDCAuthIntegration(input);
if (!result) {
return { clientMutationId: input.clientMutationId };
}
@@ -87,6 +87,26 @@ const Mutation: GQLMutationTypeResolver<void> = {
clientMutationId: input.clientMutationId,
};
},
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.create(input),
clientMutationId: input.clientMutationId,
}),
updateStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.update(input),
clientMutationId: input.clientMutationId,
}),
mergeStories: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.merge(input),
clientMutationId: input.clientMutationId,
}),
removeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.remove(input),
clientMutationId: input.clientMutationId,
}),
scrapeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.scrape(input),
clientMutationId: input.clientMutationId,
}),
};
export default Mutation;
@@ -1052,7 +1052,7 @@ type Comment {
"""
replyCount is the number of replies. Only direct replies to this Comment
are counted. Deleted comments are included in this count.
are counted. Removed comments are included in this count.
"""
replyCount: Int!
@@ -1918,12 +1918,12 @@ type CreateCommentReactionPayload {
}
##################
## deleteCommentReaction
## removeCommentReaction
##################
input DeleteCommentReactionInput {
input RemoveCommentReactionInput {
"""
commentID is the Comment's ID that we want to delete a Reaction on.
commentID is the Comment's ID that we want to remove a Reaction on.
"""
commentID: ID!
@@ -1933,9 +1933,9 @@ input DeleteCommentReactionInput {
clientMutationId: String!
}
type DeleteCommentReactionPayload {
type RemoveCommentReactionPayload {
"""
comment is the Comment that the Reaction was deleted on.
comment is the Comment that the Reaction was removed on.
"""
comment: Comment
@@ -1974,12 +1974,12 @@ type CreateCommentDontAgreePayload {
}
##################
## deleteCommentDontAgree
## removeCommentDontAgree
##################
input DeleteCommentDontAgreeInput {
input RemoveCommentDontAgreeInput {
"""
commentID is the Comment's ID that we want to delete a DontAgree on.
commentID is the Comment's ID that we want to remove a DontAgree on.
"""
commentID: ID!
@@ -1989,9 +1989,9 @@ input DeleteCommentDontAgreeInput {
clientMutationId: String!
}
type DeleteCommentDontAgreePayload {
type RemoveCommentDontAgreePayload {
"""
comment is the Comment that the DontAgree was deleted on.
comment is the Comment that the DontAgree was removed on.
"""
comment: Comment
@@ -2035,12 +2035,12 @@ type CreateCommentFlagPayload {
}
##################
## deleteCommentFlag
## removeCommentFlag
##################
input DeleteCommentFlagInput {
input RemoveCommentFlagInput {
"""
commentID is the Comment's ID that we want to delete a Flag on.
commentID is the Comment's ID that we want to remove a Flag on.
"""
commentID: ID!
@@ -2050,9 +2050,9 @@ input DeleteCommentFlagInput {
clientMutationId: String!
}
type DeleteCommentFlagPayload {
type RemoveCommentFlagPayload {
"""
comment is the Comment that the Flag was deleted on.
comment is the Comment that the Flag was removed on.
"""
comment: Comment
@@ -2278,10 +2278,10 @@ type UpdateOIDCAuthIntegrationPayload {
}
##################
# deleteOIDCAuthIntegration
# removeOIDCAuthIntegration
##################
input DeleteOIDCAuthIntegrationInput {
input RemoveOIDCAuthIntegrationInput {
"""
id is the ID of the specific OpenID Connect integration that we are deleting.
"""
@@ -2293,14 +2293,14 @@ input DeleteOIDCAuthIntegrationInput {
clientMutationId: String!
}
type DeleteOIDCAuthIntegrationPayload {
type RemoveOIDCAuthIntegrationPayload {
"""
integration is the OIDCAuthIntegration we just deleted.
integration is the OIDCAuthIntegration we just removed.
"""
integration: OIDCAuthIntegration
"""
settings is the Settings that the OIDCAuthIntegration was deleted on with the
settings is the Settings that the OIDCAuthIntegration was removed on with the
OIDCAuthIntegration removed from it.
"""
settings: Settings
@@ -2311,6 +2311,252 @@ type DeleteOIDCAuthIntegrationPayload {
clientMutationId: String!
}
##################
## createStory
##################
"""
StoryMetadataInput is the metadata for a given Story as provided via this API.
"""
input StoryMetadataInput {
"""
title stores the title from the Story page.
"""
title: String
"""
author stores the author from the Story page.
"""
author: String
"""
description stores the description from the Story page.
"""
description: String
"""
image stores the image from the Story page.
"""
image: String
"""
publishedAt stores the publication date from the Story page.
"""
publishedAt: Time
"""
modifiedAt stores the modified date from the Story page.
"""
modifiedAt: Time
"""
section stores the section from the Story page.
"""
section: String
}
"""
CreateStory is the input required to create a Story.
"""
input CreateStory {
"""
id is the identifier of the Story.
"""
id: ID!
"""
url is the url that the Story is located on.
"""
url: String!
"""
metadata is the set of information relating to this Story that would normally
be scraped, but can be provided here.
"""
metadata: StoryMetadataInput
}
input CreateStoryInput {
"""
story is the Story input needed to create a Story.
"""
story: CreateStory!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CreateStoryPayload {
"""
story is the Story that was possibly created.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## updateStory
##################
"""
UpdateStory is the input required to update a Story.
"""
input UpdateStory {
"""
url is the url that the Story is located on.
"""
url: String
"""
metadata is the set of information relating to this Story that would normally
be scraped, but can be provided here.
"""
metadata: StoryMetadataInput
"""
isClosed is true when the Story should be closed for commenting.
"""
isClosed: Boolean
}
input UpdateStoryInput {
"""
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!
"""
story contains the fields that should be updated. Any fields not specified
will not be changed.
"""
story: UpdateStory!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateStoryPayload {
"""
story is the Story that was possibly updated.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## mergeStories
##################
input MergeStoriesInput {
"""
sourceIDs are the list of Story ID's that should have their Comment's moved
onto the Story indicated by `destinationID`. The Stories indicated by the
`sourceIDs` field will be removed after the Comment's have been moved.
"""
sourceIDs: [ID!]!
"""
destinationID is the ID of the Story where all the other "source" Stories will
have their Comment's moved onto.
"""
destinationID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type MergeStoriesPayload {
"""
story is the Story that all the source stories were merged into.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## removeStory
##################
input RemoveStoryInput {
"""
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!
"""
includeComments when true will remove any Comment's that were left on the
Story. This option should be used rarely, instead preferring to updating the
Story URL and/or merging with the correct Story.
"""
includeComments: Boolean = false
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveStoryPayload {
"""
story is the Story that was possibly removed.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## scrapeStory
##################
input ScrapeStoryInput {
"""
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 ScrapeStoryPayload {
"""
story is the Story that was possibly scraped.
"""
story: Story
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -2355,12 +2601,12 @@ type Mutation {
): UpdateOIDCAuthIntegrationPayload @auth(roles: [ADMIN])
"""
deleteOIDCAuthIntegration will delete the specified OpenID Connect auth
removeOIDCAuthIntegration will remove the specified OpenID Connect auth
integration.
"""
deleteOIDCAuthIntegration(
input: DeleteOIDCAuthIntegrationInput!
): DeleteOIDCAuthIntegrationPayload @auth(roles: [ADMIN])
removeOIDCAuthIntegration(
input: RemoveOIDCAuthIntegrationInput!
): RemoveOIDCAuthIntegrationPayload @auth(roles: [ADMIN])
"""
createCommentReaction will create a Reaction authored by the current logged in
@@ -2371,10 +2617,10 @@ type Mutation {
): CreateCommentReactionPayload @auth
"""
deleteCommentReaction will delete a Reaction authored by the current logged in
removeCommentReaction will remove a Reaction authored by the current logged in
User on a Comment if it exists.
"""
deleteCommentReaction(
removeCommentReaction(
input: CreateCommentReactionInput!
): CreateCommentReactionPayload @auth
@@ -2387,10 +2633,10 @@ type Mutation {
): CreateCommentDontAgreePayload @auth
"""
deleteCommentDontAgree will delete a DontAgree authored by the current logged in
removeCommentDontAgree will remove a DontAgree authored by the current logged in
User on a Comment if it exists.
"""
deleteCommentDontAgree(
removeCommentDontAgree(
input: CreateCommentDontAgreeInput!
): CreateCommentDontAgreePayload @auth
@@ -2402,11 +2648,42 @@ type Mutation {
@auth
"""
deleteCommentFlag will create a Flag authored by the current logged in User on
removeCommentFlag will create a Flag authored by the current logged in User on
a given Comment.
"""
deleteCommentFlag(input: DeleteCommentFlagInput!): DeleteCommentFlagPayload
removeCommentFlag(input: RemoveCommentFlagInput!): RemoveCommentFlagPayload
@auth
"""
createStory will create the provided Story.
"""
createStory(input: CreateStoryInput!): CreateStoryPayload
@auth(roles: [ADMIN])
"""
updateStory will update the given Story.
"""
updateStory(input: UpdateStoryInput!): UpdateStoryPayload
@auth(roles: [ADMIN])
"""
mergeStories will merge two stories together, merging their comment streams.
This operation is irreversible.
"""
mergeStories(input: MergeStoriesInput!): MergeStoriesPayload
@auth(roles: [ADMIN])
"""
removeStory will remove the given Story.
"""
removeStory(input: RemoveStoryInput!): RemoveStoryPayload
@auth(roles: [ADMIN])
"""
scrapeStory will scrape the given Story and update the scraped metadata.
"""
scrapeStory(input: ScrapeStoryInput!): ScrapeStoryPayload
@auth(roles: [ADMIN, MODERATOR])
}
################################################################################
+1 -1
View File
@@ -5,7 +5,7 @@ import config, { Config } from "talk-common/config";
import getManagementSchema from "talk-server/graph/management/schema";
import { Schemas } from "talk-server/graph/schemas";
import getTenantSchema from "talk-server/graph/tenant/schema";
import { createQueue } from "talk-server/services/queue";
import { createQueue } from "talk-server/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { createJWTSigningConfig } from "talk-server/services/jwt";
+116 -11
View File
@@ -56,13 +56,54 @@ export type FLAG_REASON =
| GQLCOMMENT_FLAG_REASON;
export interface Action extends TenantResource {
/**
* id is the identifier for this specific Action.
*/
readonly id: string;
/**
* action_type is the type of Action that this represents.
*/
action_type: ACTION_TYPE;
/**
* item_type enables polymorphic behavior be allowing multiple item types
* to be represented in a single collection.
*/
item_type: ACTION_ITEM_TYPE;
/**
* item_id is the ID of the specific item that this Action is associated with.
*/
item_id: string;
/**
* reason is the reason or secondary grouping identifier for why this
* particular action was left.
*/
reason?: FLAG_REASON;
/**
* root_item_id represents the identifier for the item's associated item. In
* the case of a REACTION left on a Comment, this ID would be the Stories ID.
* In the case of a FLAG left on a User, this ID would be null.
*/
root_item_id?: string;
/**
* user_id is the ID of the User that left this Action. In the event that the
* Action was left by Talk, it will be null.
*/
user_id?: string;
/**
* created_at is the date that this particular Action was created at.
*/
created_at: Date;
/**
* metadata is arbitrary information stored for this Action.
*/
metadata?: Record<string, any>;
}
@@ -248,36 +289,36 @@ export async function retrieveManyUserActionPresence(
);
}
export type DeleteActionInput = Pick<
export type RemoveActionInput = Pick<
Action,
"action_type" | "item_type" | "item_id" | "reason" | "user_id"
>;
/**
* The result returned by `deleteAction`.
* The result returned by `removeAction`.
*/
export interface DeletedActionResultObject {
export interface RemovedActionResultObject {
/**
* action is the action that was deleted.
*/
action?: Action;
/**
* wasDeleted is true when the action that was supposed to be deleted was
* wasRemoved is true when the action that was supposed to be deleted was
* actually deleted.
*/
wasDeleted: boolean;
wasRemoved: boolean;
}
/**
* deleteAction will delete the action based on the form of the action rather
* removeAction will delete the action based on the form of the action rather
* than a specific action by ID.
*/
export async function deleteAction(
export async function removeAction(
mongo: Db,
tenantID: string,
input: DeleteActionInput
): Promise<DeletedActionResultObject> {
input: RemoveActionInput
): Promise<RemovedActionResultObject> {
// Extract the filter parameters.
const filter: FilterQuery<Action> = {
tenant_id: tenantID,
@@ -297,7 +338,7 @@ export async function deleteAction(
const result = await collection(mongo).findOneAndDelete(filter);
return {
action: result.value,
wasDeleted: Boolean(result.ok && result.value),
wasRemoved: Boolean(result.ok && result.value),
};
}
@@ -455,6 +496,30 @@ function createEmptyActionCounts(): GQLActionCounts {
};
}
export function mergeActionCounts(
actionCounts: EncodedActionCounts[]
): EncodedActionCounts {
const mergedActionCounts: EncodedActionCounts = {};
for (const counts of actionCounts) {
for (const [key, count] of Object.entries(counts)) {
if (key in mergedActionCounts) {
mergedActionCounts[key] += count;
} else {
mergedActionCounts[key] += 1;
}
}
}
return mergedActionCounts;
}
export function countTotalActionCounts(
actionCounts: EncodedActionCounts
): number {
return Object.values(actionCounts).reduce((total, count) => total + count, 0);
}
/**
* decodeActionCounts will take the encoded action counts and decode them into
* a useable format.
@@ -469,7 +534,7 @@ export function decodeActionCounts(
// Loop over all the encoded action counts to extract each of the action
// counts as they are encoded.
Object.entries(encodedActionCounts).map(([key, count]) => {
Object.entries(encodedActionCounts).forEach(([key, count]) => {
// Pull out the action type and the reason from the key.
const { actionType, reason } = decodeActionCountKey(key);
@@ -509,3 +574,43 @@ function incrementActionCounts(
return actionCounts;
}
/**
* removeRootActions will remove all the Action's associated with a given root
* identifier.
*/
export async function removeRootActions(
mongo: Db,
tenantID: string,
rootItemID: string
) {
return collection(mongo).deleteMany({
tenant_id: tenantID,
root_item_id: rootItemID,
});
}
/**
* mergeManyRootActions will update many Action `root_item_id'`s from one to
* another.
*/
export async function mergeManyRootActions(
mongo: Db,
tenantID: string,
newRootItemID: string,
oldRootItemIDs: string[]
) {
return collection(mongo).updateMany(
{
tenant_id: tenantID,
root_item_id: {
$in: oldRootItemIDs,
},
},
{
$set: {
root_item_id: newRootItemID,
},
}
);
}
+40
View File
@@ -550,3 +550,43 @@ export async function updateCommentActionCounts(
return result.value;
}
/**
* removeStoryComments will remove all comments associated with a particular
* Story.
*/
export async function removeStoryComments(
mongo: Db,
tenantID: string,
storyID: string
) {
// Delete all the comments written on a specific story.
return collection(mongo).deleteMany({
tenant_id: tenantID,
story_id: storyID,
});
}
/**
* mergeManyCommentStories will update many comment's storyID's.
*/
export async function mergeManyCommentStories(
mongo: Db,
tenantID: string,
newStoryID: string,
oldStoryIDs: string[]
) {
return collection(mongo).updateMany(
{
tenant_id: tenantID,
story_id: {
$in: oldStoryIDs,
},
},
{
$set: {
story_id: newStoryID,
},
}
);
}
+147 -10
View File
@@ -1,4 +1,4 @@
import { Db } from "mongodb";
import { Db, MongoError } from "mongodb";
import uuid from "uuid";
import { Omit } from "talk-common/types";
@@ -149,6 +149,37 @@ export async function updateCommentStatusCount(
return result.value || null;
}
/**
* mergeCommentStatusCount will merge an array of commentStatusCount's into one.
*/
export function mergeCommentStatusCount(
commentStatusCounts: CommentStatusCounts[]
): CommentStatusCounts {
const statusCounts = createEmptyCommentCounts();
for (const commentCounts of commentStatusCounts) {
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
statusCounts[status] += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
}
return statusCounts;
}
function createEmptyCommentCounts(): CommentStatusCounts {
return {
[GQLCOMMENT_STATUS.ACCEPTED]: 0,
@@ -191,6 +222,46 @@ export async function findOrCreateStory(
return upsertStory(db, tenantID, { url });
}
export type CreateStoryInput = Partial<Pick<Story, "metadata">>;
export async function createStory(
mongo: Db,
tenantID: string,
id: string,
url: string,
input: CreateStoryInput
) {
const now = new Date();
// Create the story.
const story: Story = {
...input,
id,
url,
tenant_id: tenantID,
created_at: now,
action_counts: {},
comment_counts: createEmptyCommentCounts(),
};
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) {
// TODO: (wyattjoh) return better error
throw new Error("story with this url already exists");
}
throw err;
}
// Return the created story.
return story;
}
export async function retrieveStoryByURL(
db: Db,
tenantID: string,
@@ -235,7 +306,7 @@ export async function retrieveManyStoriesByURL(
export type UpdateStoryInput = Omit<
Partial<Story>,
"id" | "tenant_id" | "url" | "created_at"
"id" | "tenant_id" | "created_at"
>;
export async function updateStory(
@@ -253,15 +324,26 @@ export async function updateStory(
},
};
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
try {
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
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) {
// TODO: (wyattjoh) return better error
throw new Error("story with this url already exists");
}
throw err;
}
}
/**
@@ -291,3 +373,58 @@ export async function updateStoryActionCounts(
return result.value || null;
}
export async function removeStory(mongo: Db, tenantID: string, id: string) {
const result = await collection(mongo).findOneAndDelete({
id,
tenant_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({
tenant_id: tenantID,
id: {
$in: ids,
},
});
}
/**
* calculateTotalCommentCount will compute the total amount of comments left on
* an Asset by parsing the `CommentStatusCounts`.
*/
export function calculateTotalCommentCount(
commentCounts: CommentStatusCounts
): number {
let count = 0;
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
count += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
return count;
}
+8 -8
View File
@@ -352,25 +352,25 @@ export async function updateTenantOIDCAuthIntegration(
};
}
export interface DeleteTenantOIDCAuthIntegrationResultObject {
export interface RemoveTenantOIDCAuthIntegrationResultObject {
tenant?: Tenant;
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
wasDeleted: boolean;
wasRemoved: boolean;
}
/**
* deleteTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth
* removeTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth
* Integration on the Tenant.
*
* @param mongo MongoDB Database handle
* @param id the id of the Tenant
* @param oidcID the id of the OpenID Connect Auth Integration we're deleting
*/
export async function deleteTenantOIDCAuthIntegration(
export async function removeTenantOIDCAuthIntegration(
mongo: Db,
id: string,
oidcID: string
): Promise<DeleteTenantOIDCAuthIntegrationResultObject> {
): Promise<RemoveTenantOIDCAuthIntegrationResultObject> {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
@@ -384,7 +384,7 @@ export async function deleteTenantOIDCAuthIntegration(
}
);
if (!result.value) {
return { wasDeleted: false };
return { wasRemoved: false };
}
// Find the integration that we wanted to delete.
@@ -394,7 +394,7 @@ export async function deleteTenantOIDCAuthIntegration(
if (!integration) {
// The integration was not in the original document, so we could not have
// possibly deleted it!
return { wasDeleted: false };
return { wasRemoved: false };
}
// The integration was found, we should pull that integration out of the
@@ -406,6 +406,6 @@ export async function deleteTenantOIDCAuthIntegration(
return {
tenant: result.value,
integration,
wasDeleted: true,
wasRemoved: true,
};
}
@@ -2,15 +2,12 @@ import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import Task from "talk-server/services/queue/Task";
import {
createMailerTask,
Mailer,
} from "talk-server/services/queue/tasks/mailer";
import Task from "talk-server/queue/Task";
import { createMailerTask, Mailer } from "talk-server/queue/tasks/mailer";
import {
createScraperTask,
ScraperData,
} from "talk-server/services/queue/tasks/scraper";
} from "talk-server/queue/tasks/scraper";
import { createRedisClient } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
@@ -6,8 +6,8 @@ import { createTransport } from "nodemailer";
import { Config } from "talk-common/config";
import logger from "talk-server/logger";
import Task from "talk-server/services/queue/Task";
import MailerContent from "talk-server/services/queue/tasks/mailer/content";
import Task from "talk-server/queue/Task";
import MailerContent from "talk-server/queue/tasks/mailer/content";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
@@ -0,0 +1,55 @@
import Queue, { Job } from "bull";
import { Db } from "mongodb";
import logger from "talk-server/logger";
import Task from "talk-server/queue/Task";
import { scrape } from "talk-server/services/stories/scraper";
const JOB_NAME = "scraper";
export interface ScrapeProcessorOptions {
mongo: Db;
}
export interface ScraperData {
storyID: string;
storyURL: string;
tenantID: string;
}
const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
job: Job<ScraperData>
) => {
// Pull out the job data.
const { storyID, storyURL, tenantID } = job.data;
const log = logger.child({
job_id: job.id,
job_name: JOB_NAME,
story_id: storyID,
story_url: storyURL,
tenant_id: tenantID,
});
log.debug("starting to scrape the story");
try {
await scrape(mongo, tenantID, storyID, storyURL);
log.debug("scraped the story");
} catch (err) {
log.error({ err }, "could not scrape the story");
throw err;
}
};
export function createScraperTask(
queue: Queue.QueueOptions,
options: ScrapeProcessorOptions
) {
return new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
});
}
+26 -16
View File
@@ -1,15 +1,16 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
ACTION_TYPE,
CreateActionInput,
createActions,
deleteAction,
DeleteActionInput,
encodeActionCounts,
invertEncodedActionCounts,
removeAction,
RemoveActionInput,
} from "talk-server/models/action";
import {
retrieveComment,
@@ -20,11 +21,14 @@ import { updateStoryActionCounts } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
export type CreateAction = Omit<CreateActionInput, "root_item_id"> &
Required<Pick<CreateActionInput, "root_item_id">>;
export async function addCommentActions(
mongo: Db,
tenant: Tenant,
comment: Readonly<Comment>,
inputs: CreateActionInput[]
inputs: CreateAction[]
): Promise<Readonly<Comment>> {
// Create each of the actions, returning each of the action results.
const results = await createActions(mongo, tenant.id, inputs);
@@ -79,13 +83,19 @@ async function addCommentAction(
throw new Error("comment not found");
}
return addCommentActions(mongo, tenant, comment, [input]);
// Store the story ID on the action as a story_id.
input.root_item_id = comment.story_id;
// We have to perform a type assertion here because for some reason, the type
// coercion is not determining that because we filled in the `root_item_id`
// above, that at this point, it satisfies the CreateAction type.
return addCommentActions(mongo, tenant, comment, [input as CreateAction]);
}
export async function removeCommentAction(
mongo: Db,
tenant: Tenant,
input: DeleteActionInput
input: RemoveActionInput
): Promise<Readonly<Comment>> {
// Get the Comment that we are leaving the Action on.
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
@@ -95,8 +105,8 @@ export async function removeCommentAction(
}
// Create each of the actions, returning each of the action results.
const { wasDeleted, action } = await deleteAction(mongo, tenant.id, input);
if (wasDeleted) {
const { wasRemoved, action } = await removeAction(mongo, tenant.id, input);
if (wasRemoved) {
// Compute the action counts, and invert them (because we're deleting an
// action).
const actionCounts = invertEncodedActionCounts(encodeActionCounts(action!));
@@ -145,13 +155,13 @@ export async function createReaction(
});
}
export type DeleteCommentReaction = Pick<DeleteActionInput, "item_id">;
export type RemoveCommentReaction = Pick<RemoveActionInput, "item_id">;
export async function deleteReaction(
export async function removeReaction(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentReaction
input: RemoveCommentReaction
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
@@ -177,13 +187,13 @@ export async function createDontAgree(
});
}
export type DeleteCommentDontAgree = Pick<DeleteActionInput, "item_id">;
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "item_id">;
export async function deleteDontAgree(
export async function removeDontAgree(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentDontAgree
input: RemoveCommentDontAgree
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
@@ -212,13 +222,13 @@ export async function createFlag(
});
}
export type DeleteCommentFlag = Pick<DeleteActionInput, "item_id">;
export type RemoveCommentFlag = Pick<RemoveActionInput, "item_id">;
export async function deleteFlag(
export async function removeFlag(
mongo: Db,
tenant: Tenant,
author: User,
input: DeleteCommentFlag
input: RemoveCommentFlag
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
+13 -4
View File
@@ -1,7 +1,7 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { ACTION_ITEM_TYPE, CreateActionInput } from "talk-server/models/action";
import { ACTION_ITEM_TYPE } from "talk-server/models/action";
import {
createComment,
CreateCommentInput,
@@ -16,7 +16,10 @@ import {
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { addCommentActions } from "talk-server/services/comments/actions";
import {
addCommentActions,
CreateAction,
} from "talk-server/services/comments/actions";
import { processForModeration } from "talk-server/services/comments/moderation";
import { Request } from "talk-server/types/express";
@@ -83,10 +86,13 @@ export async function create(
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateActionInput => ({
(action): CreateAction => ({
...action,
item_id: comment.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
// Store the Story ID on the action.
root_item_id: story.id,
})
);
@@ -171,12 +177,15 @@ export async function edit(
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateActionInput => ({
(action): CreateAction => ({
...action,
// Strict null check seems to have failed here... Null checking was done
// above where we errored if the comment was falsely.
item_id: comment!.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
// Store the Story ID on the action.
root_item_id: story.id,
})
);
+267 -7
View File
@@ -1,3 +1,4 @@
import { zip } from "lodash";
import { Db } from "mongodb";
import {
@@ -8,12 +9,36 @@ import {
} from "talk-server/app/url";
import logger from "talk-server/logger";
import {
countTotalActionCounts,
mergeActionCounts,
mergeManyRootActions,
removeRootActions,
} from "talk-server/models/action";
import {
mergeManyCommentStories,
removeStoryComments,
} from "talk-server/models/comment";
import {
calculateTotalCommentCount,
createStory,
CreateStoryInput,
findOrCreateStory,
FindOrCreateStoryInput,
mergeCommentStatusCount,
removeStories,
removeStory,
retrieveManyStories,
retrieveStory,
Story,
updateCommentStatusCount,
updateStory,
updateStoryActionCounts,
UpdateStoryInput,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import Task from "talk-server/services/queue/Task";
import { ScraperData } from "talk-server/services/queue/tasks/scraper";
import Task from "talk-server/queue/Task";
import { ScraperData } from "talk-server/queue/tasks/scraper";
import { scrape } from "talk-server/services/stories/scraper";
export type FindOrCreateStory = FindOrCreateStoryInput;
@@ -27,21 +52,24 @@ export async function findOrCreate(
// to create the Asset.
if (input.url && !isURLPermitted(tenant, input.url)) {
logger.warn(
{ storyURL: input.url, tenantDomains: tenant.domains },
"provided story url was not in the list of permitted tenant domains"
{ story_url: input.url, tenant_domains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not found"
);
return null;
}
// TODO: check to see if the tenant has enabled lazy story creation.
// TODO: check to see if the tenant has enabled lazy story creation, if they haven't, switch to find only.
const story = await findOrCreateStory(db, tenant.id, input);
if (!story) {
return null;
}
if (!story.scrapedAt) {
// If the scraper has not scraped this story, we need to scrape it now!
// TODO: check to see if the tenant has scraping enabled.
if (!story.metadata && !story.scrapedAt) {
// If the scraper has not scraped this story, and we have no metadata, we
// need to scrape it now!
await scraper.add({
storyID: story.id,
storyURL: story.url,
@@ -86,3 +114,235 @@ export function isURLPermitted(
.map(domain => getOrigin(prefixSchemeIfRequired(originSecure, domain)))
.some(origin => origin === targetOrigin);
}
export async function remove(
mongo: Db,
tenant: Tenant,
storyID: string,
includeComments: boolean = false
) {
// Create a logger for this function.
const log = logger.child({
story_id: storyID,
include_comments: includeComments,
});
log.debug("starting to remove story");
// Get the story so we can see if there are associated comments.
const story = await retrieveStory(mongo, tenant.id, storyID);
if (!story) {
// No story was found!
log.warn("attempted to remove story that wasn't found");
return null;
}
if (includeComments) {
let removedCount: number | undefined;
// Remove the actions associated with the comments we just removed.
({ deletedCount: removedCount } = await removeRootActions(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_actions: removedCount },
"removed actions while deleting story"
);
// Remove the comments for the story.
({ deletedCount: removedCount } = await removeStoryComments(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_comments: removedCount },
"removed comments while deleting story"
);
} else if (calculateTotalCommentCount(story.comment_counts) > 0) {
log.warn(
"attempted to remove story that has linked comments without consent for deleting comments"
);
// TODO: (wyattjoh) improve error
throw new Error("asset has comments, cannot remove");
}
const removedStory = await removeStory(mongo, tenant.id, story.id);
if (!removedStory) {
// Story was already removed.
// TODO: evaluate use of transaction here.
return null;
}
log.debug("removed story");
return removedStory;
}
export type CreateStory = CreateStoryInput;
export async function create(
mongo: Db,
tenant: Tenant,
storyID: string,
storyURL: string,
input: CreateStory
) {
// Ensure that the given URL is allowed.
if (!isURLPermitted(tenant, storyURL)) {
logger.warn(
{ story_url: storyURL, tenant_domains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not created"
);
return null;
}
// Create the story in the database.
let newStory = await createStory(mongo, tenant.id, storyID, storyURL, input);
if (!input.metadata && !newStory.scrapedAt) {
// If the scraper has not scraped this story and story metadata was not
// provided, we need to scrape it now!
newStory = await scrape(mongo, tenant.id, newStory.id);
}
return newStory;
}
export type UpdateStory = UpdateStoryInput;
export async function update(
mongo: Db,
tenant: Tenant,
storyID: string,
input: UpdateStory
) {
// Ensure that the given URL is allowed.
if (input.url && !isURLPermitted(tenant, input.url)) {
logger.warn(
{ story_url: input.url, tenant_domains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not updated"
);
return null;
}
return updateStory(mongo, tenant.id, storyID, input);
}
export async function merge(
mongo: Db,
tenant: Tenant,
destinationID: string,
sourceIDs: string[]
) {
// Create a logger for this operation.
const log = logger.child({
destination_id: destinationID,
source_ids: sourceIDs,
});
if (sourceIDs.length === 0) {
log.warn("cannot merge from 0 stories");
return null;
}
// Get the stories referenced.
const storyIDs = [destinationID, ...sourceIDs];
const stories = await retrieveManyStories(mongo, tenant.id, storyIDs);
// Ensure that these are all defined.
if (
zip(storyIDs, stories).some(([storyID, story]) => {
if (!story) {
log.warn(
{ story_id: storyID },
"story that was going to be merged was not found"
);
return true;
}
return false;
})
) {
return null;
}
let updatedCount: number | undefined;
// Move all the comment's from the source stories over to the destination
// story.
({ modifiedCount: updatedCount } = await mergeManyCommentStories(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_comments: updatedCount },
"updated comments while merging stories"
);
// Update all the action's that referenced the old story to reference the new
// story.
({ modifiedCount: updatedCount } = await mergeManyRootActions(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_actions: updatedCount },
"updated actions while merging stories"
);
// Merge the comment and action counts for all the source stories.
const [, ...sourceStories] = stories;
let destinationStory = await updateCommentStatusCount(
mongo,
tenant.id,
destinationID,
mergeCommentStatusCount(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ comment_counts }) => comment_counts)
)
);
const mergedActionCounts = mergeActionCounts(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ action_counts }) => action_counts)
);
if (countTotalActionCounts(mergedActionCounts) > 0) {
destinationStory = await updateStoryActionCounts(
mongo,
tenant.id,
destinationID,
mergedActionCounts
);
}
if (!destinationStory) {
log.warn("destination story cannot be updated with new comment counts");
return null;
}
log.debug(
{ comment_counts: destinationStory.comment_counts },
"updated destination story with new comment counts"
);
const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs);
log.debug({ deleted_stories: deletedCount }, "deleted source stories");
// Return the story that had the other stories merged into.
return destinationStory;
}
@@ -1,4 +1,3 @@
import Queue, { Job } from "bull";
import Logger from "bunyan";
import cheerio from "cheerio";
import authorScraper from "metascraper-author";
@@ -10,87 +9,11 @@ import { Db } from "mongodb";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { updateStory } from "talk-server/models/story";
import Task from "talk-server/services/queue/Task";
import { retrieveStory, updateStory } from "talk-server/models/story";
import { modifiedScraper } from "./rules/modified";
import { sectionScraper } from "./rules/section";
const JOB_NAME = "scraper";
export interface ScrapeProcessorOptions {
mongo: Db;
}
export interface ScraperData {
storyID: string;
storyURL: string;
tenantID: string;
}
const createJobProcessor = (options: ScrapeProcessorOptions) => async (
job: Job<ScraperData>
) => {
// Pull out the job data.
const { storyID: id, storyURL: url, tenantID } = job.data;
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
story_id: id,
story_url: url,
tenant_id: tenantID,
},
"starting to scrap the story"
);
// Get the metadata from the scraped html.
const metadata = await scraper.scrape(url);
if (!metadata) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
story_id: id,
story_url: url,
tenant_id: tenantID,
},
"story at specified url not found, can not scrape"
);
return;
}
// Update the Story with the scraped details.
const story = await updateStory(options.mongo, tenantID, id, {
metadata,
scrapedAt: new Date(),
});
if (!story) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
story_id: id,
story_url: url,
tenant_id: tenantID,
},
"story at specified id not found, can not update with metadata"
);
return;
}
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
story_id: story.id,
story_url: url,
tenant_id: tenantID,
},
"scraped the story"
);
};
export type Rule = Record<
string,
Array<
@@ -188,13 +111,37 @@ function createScraper() {
export const scraper = createScraper();
export function createScraperTask(
queue: Queue.QueueOptions,
options: ScrapeProcessorOptions
export async function scrape(
mongo: Db,
tenantID: string,
storyID: string,
storyURL?: string
) {
return new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
// If the URL wasn't provided, grab it from the database.
if (!storyURL) {
const retrievedStory = await retrieveStory(mongo, tenantID, storyID);
if (!retrievedStory) {
throw new Error("story at specified id not found");
}
// Update the story URL.
storyURL = retrievedStory.url;
}
// Get the metadata from the scraped html.
const metadata = await scraper.scrape(storyURL);
if (!metadata) {
throw new Error("story at specified url not found");
}
// Update the Story with the scraped details.
const story = await updateStory(mongo, tenantID, storyID, {
metadata,
scrapedAt: new Date(),
});
if (!story) {
throw new Error("story at specified id not found");
}
return story;
}
+4 -4
View File
@@ -11,8 +11,8 @@ import {
createTenant,
CreateTenantInput,
createTenantOIDCAuthIntegration,
deleteTenantOIDCAuthIntegration,
regenerateTenantSSOKey,
removeTenantOIDCAuthIntegration,
Tenant,
updateTenant,
updateTenantOIDCAuthIntegration,
@@ -176,7 +176,7 @@ export async function updateOIDCAuthIntegration(
return result;
}
export async function deleteOIDCAuthIntegration(
export async function removeOIDCAuthIntegration(
mongo: Db,
redis: Redis,
cache: TenantCache,
@@ -184,12 +184,12 @@ export async function deleteOIDCAuthIntegration(
oidcID: string
) {
// Delete the integration. By default, the integration is disabled.
const result = await deleteTenantOIDCAuthIntegration(
const result = await removeTenantOIDCAuthIntegration(
mongo,
tenant.id,
oidcID
);
if (!result.wasDeleted || !result.tenant) {
if (!result.wasRemoved || !result.tenant) {
return null;
}